commit a23ba685e9b74af06b49a393acd9351d5a112e7a Author: 谢宇宁 Date: Tue Sep 15 15:44:13 2026 +0700 初始化 diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..a06c1db --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.38.3" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abb6d3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,190 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +.properties +.flutter-plugins +.packages +.lock +.bin +pubspec.look +local.properties +.packages +.flutter-plugins +searchFile.js + + + +.packages +.metadata +.fvm/ +# VSCode 工作区配置纳入版本管理(settings.json 里的排除规则要团队共用), +# 其余个人化/临时文件仍然忽略。 +# 注意:全局 ~/.gitignore_global 也忽略了这两个文件,靠项目级白名单覆盖 +.vscode/* +!.vscode/settings.json +!.vscode/launch.json +!.vscode/extensions.json +zaixiankefu/pubspec.lock +# 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/ +.dart_tool/* +.flutter-plugins +.packages +.pub-cache/ +.pub/ +/build/ + + +# Android related +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + + +# 针对flutter当做module集成进现有项目时,.android目录下的文件 +**/.android/**/gradle-wrapper.jar +**/.android/.gradle +**/.android/captures/ +**/.android/gradlew +**/.android/gradlew.bat +**/.android/local.properties +**/.android/**/GeneratedPluginRegistrant.java +**/.android/key.properties +**/*.jks + + + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + + +# 针对flutter当做module集成进现有项目时,.ios目录下的文件 +**/.ios/**/*.mode1v3 +**/.ios/**/*.mode2v3 +**/.ios/**/*.moved-aside +**/.ios/**/*.pbxuser +**/.ios/**/*.perspectivev3 +**/.ios/**/*sync/ +**/.ios/**/.sconsign.dblite +**/.ios/**/.tags* +**/.ios/**/.vagrant/ +**/.ios/**/DerivedData/ +**/.ios/**/Icon? +**/.ios/**/Pods/ +**/.ios/**/.symlinks/ +**/.ios/**/profile +**/.ios/**/xcuserdata +**/.ios/.generated/ +**/.ios/Flutter/App.framework +**/.ios/Flutter/Flutter.framework +**/.ios/Flutter/Generated.xcconfig +**/.ios/Flutter/app.flx +**/.ios/Flutter/app.zip +**/.ios/Flutter/flutter_assets/ +**/.ios/ServiceDefinitions.json +**/.ios/Runner/GeneratedPluginRegistrant.* + + + + +# macOS +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/Flutter-Debug.xcconfig +**/macos/Flutter/Flutter-Release.xcconfig +**/macos/Flutter/Flutter-Profile.xcconfig + + +# Coverage +coverage/ + + +# Symbols +app.*.symbols + + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock +.packages~92442170bbeb67da2b4cc92142720c48c0d7f490 + +/pubspec.lock +/.flutter-plugins-dependencies +linux/* +!ios/Podfile +!ios/ExportOptions.plist.example + +m3u8_downloader-master/.dart_tool/ +m3u8_downloader-master/example/.dart_tool/ +m3u8_downloader-master/example/.flutter-plugins-dependencies +m3u8_downloader-master/*/*.lock +m3u8_downloader-master/pubspec.lock +dist/ + +ume_kit/*/.dart_tool/ +ume_kit/*/.flutter-plugins-dependencies +ume_kit/*/pubspec.lock + +third_party/*/.dart_tool/ +third_party/*/.flutter-plugins-dependencies +third_party/*/pubspec.lock + + +*.apk +ume_kit/ + +# Claude Code 本地配置 +.claude/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ff017bc --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,145 @@ +{ + // 使用 IntelliSense 了解相关属性。 + // 悬停以查看现有属性的描述。 + // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "hgdj", + "request": "launch", + "type": "dart" + }, + { + "name": "hgdj (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "hgdj (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "m3u8_downloader-master", + "cwd": "m3u8_downloader-master", + "request": "launch", + "type": "dart" + }, + { + "name": "m3u8_downloader-master (profile mode)", + "cwd": "m3u8_downloader-master", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "m3u8_downloader-master (release mode)", + "cwd": "m3u8_downloader-master", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "example", + "cwd": "m3u8_downloader-master/example", + "request": "launch", + "type": "dart" + }, + { + "name": "example (profile mode)", + "cwd": "m3u8_downloader-master/example", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "example (release mode)", + "cwd": "m3u8_downloader-master/example", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flutter_native_image", + "cwd": "third_party/flutter_native_image", + "request": "launch", + "type": "dart" + }, + { + "name": "flutter_native_image (profile mode)", + "cwd": "third_party/flutter_native_image", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flutter_native_image (release mode)", + "cwd": "third_party/flutter_native_image", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "image_gallery_saver", + "cwd": "third_party/image_gallery_saver", + "request": "launch", + "type": "dart" + }, + { + "name": "image_gallery_saver (profile mode)", + "cwd": "third_party/image_gallery_saver", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "image_gallery_saver (release mode)", + "cwd": "third_party/image_gallery_saver", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "image_pickers", + "cwd": "third_party/image_pickers", + "request": "launch", + "type": "dart" + }, + { + "name": "image_pickers (profile mode)", + "cwd": "third_party/image_pickers", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "image_pickers (release mode)", + "cwd": "third_party/image_pickers", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "mac_address", + "cwd": "third_party/mac_address", + "request": "launch", + "type": "dart" + }, + { + "name": "mac_address (profile mode)", + "cwd": "third_party/mac_address", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "mac_address (release mode)", + "cwd": "third_party/mac_address", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ca5d44b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,76 @@ +{ + "dart.flutterSdkPath": ".fvm/versions/3.38.3", + "dart.flutterSdkPaths": [ + "/Users/mac/Documents/flutter" + ], + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "editor.wordWrapColumn": 120, + + //=========== 文件监听排除 =========== + // VSCode 的 watcher 不看 .gitignore,只认这里; + // Flutter 的 build/ 和 .dart_tool/ 不排掉会被整个监听 + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/build/**": true, + "**/.dart_tool/**": true, + "**/.fvm/**": true, + "**/dist/**": true, + "**/android/.gradle/**": true, + "**/android/**/build/**": true, + "**/ios/Pods/**": true, + "**/ios/.symlinks/**": true, + "**/ios/Flutter/ephemeral/**": true, + "**/*.apk": true, + "**/*.ipa": true + }, + + //=========== 全局搜索排除 =========== + "search.exclude": { + "**/build": true, + "**/.dart_tool": true, + "**/.fvm": true, + "**/dist": true, + "**/android/.gradle": true, + "**/ios/Pods": true, + "**/ios/.symlinks": true + }, + + // 不跟随软链,避免顺着 .fvm 爬进整个 Flutter SDK + "search.followSymlinks": false, + + //=========== 资源管理器隐藏纯中间产物 =========== + // dist / ios/Pods 保留可见,取包和看 Pod 配置还要用 + "files.exclude": { + "**/build": true, + "**/.dart_tool": true, + "**/android/.gradle": true, + "**/ios/.symlinks": true, + "**/ios/Flutter/ephemeral": true + }, + + //=========== Dart 分析器排除 =========== + // 只排构建产物,path 依赖(third_party 等)保持分析,避免丢跳转和补全 + "dart.analysisExcludedFolders": [ + "build", + ".dart_tool", + ".fvm", + "dist", + "android", + "ios/Pods" + ], + + //=========== GitLens 降载 =========== + // 这两项每次渲染和移动光标都触发 blame,直接拖慢滚动 + "gitlens.codeLens.enabled": false, + "gitlens.currentLine.enabled": false, + + //=========== Gradle / Java 扩展降载 =========== + // Flutter 打包直接走 gradle wrapper,不需要 Java 扩展导入这套构建 + "gradle.autoDetect": "off", + "gradle.nestedProjects": false, + "java.import.gradle.enabled": false, + "java.configuration.updateBuildConfiguration": "disabled" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d887f11 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# 黄果短剧 + +A new Flutter project. 3.16.8 + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..e87255d --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,35 @@ +# 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. +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 + + # 禁止提示要把构造函数设为 const + prefer_const_constructors: false + # 禁止提示要把集合/列表设为 const + prefer_const_literals_to_create_immutables: false + # 禁止提示在构造函数中使用 const + prefer_const_constructors_in_immutables: false + +# 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..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/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/.settings/org.eclipse.buildship.core.prefs b/android/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..e479558 --- /dev/null +++ b/android/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,13 @@ +arguments= +auto.sync=false +build.scans.enabled=false +connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) +connection.project.dir= +eclipse.preferences.version=1 +gradle.user.home= +java.home= +jvm.arguments= +offline.mode=false +override.workspace.settings=false +show.console.view=false +show.executions.view=false diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..ea599f0 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,140 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'dev.flutter.flutter-gradle-plugin' +} + +apply plugin: 'top.niunaijun.blackobfuscator' +apply plugin: 'walle' + +def bundledReleaseKeystore = file('../keystore/nut.jks') +def legacyBundledReleaseKeystore = file('../keystore/nut.jks.old') +def resolvedReleaseKeystore = bundledReleaseKeystore.exists() ? bundledReleaseKeystore : legacyBundledReleaseKeystore + +android { + namespace "com.xvproject" + compileSdk 36 + ndkVersion "28.2.13676358" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + coreLibraryDesugaringEnabled true + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + applicationId "com.noporn.newchatone_twelve_five" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.12.6" + } + + signingConfigs { + release { + storeFile resolvedReleaseKeystore + storePassword 'jiahua123456' + storeType 'jks' + keyAlias 'nut' + keyPassword 'jiahua123456' + v1SigningEnabled false + v2SigningEnabled true + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + shrinkResources true + signingConfig signingConfigs.release + } + debug { + minifyEnabled false + shrinkResources false + versionNameSuffix "test" + } + } + + packagingOptions { + jniLibs { + excludes += ['**/armeabi-v7a/**', '**/x86/**', '**/x86_64/**'] + } + } + + applicationVariants.configureEach { variant -> + println "variant.name= ${variant.name}" + variant.outputs.configureEach { + //只处理生产版本 + if (buildType.name == 'release') { + println "buildType= ${variant.buildType.getName()}" + def outputFileDir = "${project.projectDir.absolutePath}/release/" + def createTime = new Date().format("MM-dd-HH-mm", TimeZone.getTimeZone("GMT+08:00")) + println "outputFileDir= ${outputFileDir}" + def abi = filters.find { it.filterType.toString() == "ABI" }?.identifier + def abiSuffix = (variant.outputs.size() > 1 && abi) ? "_${abi}" : "" + def path = "hgdj" + '_release' + "_" + variant.versionName + abiSuffix + "_" + createTime + ".apk" + println("path= " + path) + outputFileName = path + } + } + } +} + +flutter { + source '../..' +} + +configurations.configureEach { + resolutionStrategy { + force 'androidx.core:core-ktx:1.6.0' + } +} + +configurations.all { + resolutionStrategy { + force 'com.tencent:mmkv:1.2.14' + } +} + +// 加入混淆配置 +BlackObfuscator { + // 是否启用 + enabled true + // 混淆深度 + depth 3 + // 需要混淆的包或者类(匹配前面一段) + obfClass = ["lib"] + // blackClass中的包或者类不会进行混淆(匹配前面一段) + // blackClass = ["top.niunaijun.black"] +} + +dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' + // android 读取渠道号码 + implementation 'com.mcxiaoke.packer-ng:helper:2.0.1' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.lifecycle:lifecycle-common:2.6.2' + def camerax_version = "1.0.0-beta11" + implementation "androidx.camera:camera-core:${camerax_version}" + implementation "androidx.camera:camera-camera2:${camerax_version}" + implementation "androidx.camera:camera-lifecycle:${camerax_version}" + implementation 'com.squareup.okhttp3:okhttp:4.9.3' + // 协程核心库 + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" + // 协程Android支持库 + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" + // 协程Java8支持库 + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.6.4" + implementation 'com.qiniu:happy-dns:2.0.1' + implementation 'com.github.Petterpx.walle:library:1.0.5' +} diff --git a/android/app/key/README.md b/android/app/key/README.md new file mode 100644 index 0000000..694a0ae --- /dev/null +++ b/android/app/key/README.md @@ -0,0 +1,3 @@ +运行 ./resp_genkey.sh xxxxx +xxxxx 为alias/密码和密钥的名称; +如果提示输入密钥,直接输入xxxxx即可 \ No newline at end of file diff --git a/android/app/key/genkey.sh b/android/app/key/genkey.sh new file mode 100644 index 0000000..ccf652c --- /dev/null +++ b/android/app/key/genkey.sh @@ -0,0 +1,21 @@ +#!/bin/bash +if [ $# -ne 1 ]; then + printf -- "Please input alias.\n\n" + exit +fi +echo "输入参数密钥$1\n" +keystoreName="$1.jks" +# echo "test code 2" + +printf "\n开始生成key:[$keystoreName]...\n\n" +`keytool -genkey -alias $1 -keyalg RSA -validity 10000 -keystore $keystoreName` +printf "\n[$keystoreName]正在进行pkcs12重新加密...\n\n" +`keytool -importkeystore -srckeystore $keystoreName -destkeystore $keystoreName -deststoretype pkcs12` +printf "\n删除老的key:[$keystoreName.old]...okay\n\n" +`rm -rf $keystoreName.old` + +cat /dev/null > ../../key.properties +echo "storePassword=$1" >> ../../key.properties +echo "keyPassword=$1" >> ../../key.properties +echo "keyAlias=$1" >> ../../key.properties +echo "storeFile=./key/$keystoreName" >> ../../key.properties \ No newline at end of file diff --git a/android/app/key/resp_genkey.sh b/android/app/key/resp_genkey.sh new file mode 100644 index 0000000..fcce4c0 --- /dev/null +++ b/android/app/key/resp_genkey.sh @@ -0,0 +1,74 @@ +#!/usr/bin/expect +#功能:自动填充生成androidkey功能 +#注意:expect 是用c语言写的 所以用argc和argv接收参数 +#注意:expect 只接受shell而不是bash 请./运行 + +# expect 常用命令 +# spawn 交互程序开始后面跟命令或者指定程序 +# expect 获取匹配信息匹配成功则执行expect后面的程序动作 +# send exp_send 用于发送指定的字符串信息 +# exp_continue 在expect中多次匹配就需要用到 +# send_user 用来打印输出 相当于shell中的echo +# exit 退出expect脚本 +# eof expect执行结束 退出 +# set 定义变量 +# puts 输出变量 +# set timeout 设置超时时间 + +if {$argc < 1} { + puts "Please input alias.\n\n" + exit 1 +} + +set password [lindex $argv 0] +# send_user $password +puts stderr $password +# spawn [exec bash ./genkey.sh $password] +spawn bash ./genkey.sh $password + +# expect "输入密钥库口令:" +# send "$password\r" +# expect "再次输入新口令:" +# send "$password\r" +# expect "是什么?" +# send "$password\r" +# expect "是什么?" +# send "$password\r" +# expect "是什么?" +# send "$password\r" +# expect "是什么?" +# send "$password\r" +# expect "是什么?" +# send "$password\r" +# expect "是什么?" +# send "$password\r" +# expect "是否正确?" +# send "y\r" +# expect "按回车):" +# send "\r" +# expect "输入源密钥库口令:" +# send "$password\r" +# # expect "输入目标密钥库口令:" +# # send "$password\r" +# # expect "再次输入新口令:" +# # send "$password\r" + + expect { + "输入密钥库口令:" + { send "$password\r"; exp_continue } + "再次输入新口令:" + {send "$password\r"; exp_continue} + "是什么?" + {send "$password\r"; exp_continue} + "是否正确?" + {send "y\r"; exp_continue} + "按回车):" + {send "\r"; exp_continue} + "输入源密钥库口令:" + {send "$password\r";exp_continue} + "输入目标密钥库口令:" + {send "$password\r";exp_continue} + "再次输入新口令:" + {send "$password\r";exp_continue} + } +# interact \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f835713 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +-dontwarn javax.imageio.spi.ImageInputStreamSpi +-dontwarn javax.imageio.spi.ImageOutputStreamSpi +-dontwarn javax.imageio.spi.ImageReaderSpi +-dontwarn javax.imageio.spi.ImageWriterSpi +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE + +# ffmpeg_kit_flutter_new_https_gpl:原生库 JNI_OnLoad 通过 RegisterNatives 绑定 AbiDetect.getNativeCpuAbi 等方法, +# release 下 R8 混淆改名会导致绑定失败 → JNI_OnLoad 返回 0 → 启动 SIGSEGV 闪退,必须 keep。 +-keep class com.antonkarpenko.ffmpegkit.** { *; } +# 通用兜底:任何含 native 方法的类,保留其 native 方法名(防止其它 JNI 库重蹈覆辙) +-keepclasseswithmembernames,includedescriptorclasses class * { + native ; +} 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..8684e5f --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/io/flutter/plugins/JPFileUtils.java b/android/app/src/main/java/io/flutter/plugins/JPFileUtils.java new file mode 100644 index 0000000..663c6ef --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/JPFileUtils.java @@ -0,0 +1,104 @@ +package io.flutter.plugins; + +import android.media.MediaMetadataRetriever; +import android.text.TextUtils; + +import java.io.FileInputStream; + +/// 本地视频文件信息:时长 / 分辨率 / 比例 / 高度 / 比特率 +/// 注:MediaMetadataRetriever 持有 native 资源,每次用完必须 release,否则多次调用后取不到任何信息 +public class JPFileUtils { + + /** + * 获取视频时长(秒) + */ + public static int getVideoDuration(String filePath) { + String ms = extract(filePath, MediaMetadataRetriever.METADATA_KEY_DURATION); + if (TextUtils.isEmpty(ms) || !TextUtils.isDigitsOnly(ms)) return 0; + return Integer.parseInt(ms) / 1000; + } + + /** + * 获取视频分辨率,格式 "宽*高",取不到返回空串 + */ + public static String getVideoResolution(String filePath) { + int[] size = videoSize(filePath); + return size[0] > 0 && size[1] > 0 ? size[0] + "*" + size[1] : ""; + } + + /** + * 获取视频宽高比 + */ + public static String getVideoRatio(String filePath) { + int[] size = videoSize(filePath); + if (size[0] <= 0 || size[1] <= 0) return "0"; + return String.valueOf((float) size[0] / size[1]); + } + + /** + * 获取视频高度 + */ + public static String getVideoHeight(String filePath) { + int[] size = videoSize(filePath); + if (size[0] <= 0 || size[1] <= 0) return "0"; + return String.valueOf(size[1]); + } + + /** + * 获取比特率 + */ + public static String getVideoBitrate(String filePath) { + String bitrate = extract(filePath, MediaMetadataRetriever.METADATA_KEY_BITRATE); + return TextUtils.isEmpty(bitrate) ? "0" : bitrate; + } + + /** + * 取单个 metadata;用 FD 而非路径设源,避开部分机型对中文/特殊字符路径的兼容问题 + */ + private static String extract(String filePath, int key) { + if (TextUtils.isEmpty(filePath)) return null; + MediaMetadataRetriever mmr = new MediaMetadataRetriever(); + try (FileInputStream in = new FileInputStream(filePath)) { + mmr.setDataSource(in.getFD()); + return mmr.extractMetadata(key); + } catch (Exception e) { + return null; + } finally { + release(mmr); + } + } + + /** + * 视频宽高,取不到返回 {0,0}。宽高一次打开取完,避免为了宽和高开两次文件 + */ + private static int[] videoSize(String filePath) { + int[] empty = {0, 0}; + if (TextUtils.isEmpty(filePath)) return empty; + MediaMetadataRetriever mmr = new MediaMetadataRetriever(); + try (FileInputStream in = new FileInputStream(filePath)) { + mmr.setDataSource(in.getFD()); + int width = parseInt(mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)); + int height = parseInt(mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)); + return new int[]{width, height}; + } catch (Exception e) { + return empty; + } finally { + release(mmr); + } + } + + private static int parseInt(String value) { + try { + return Integer.parseInt(value); + } catch (Exception e) { + return 0; + } + } + + private static void release(MediaMetadataRetriever mmr) { + try { + mmr.release(); + } catch (Exception ignored) { + } + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/JPNativePlugin.java b/android/app/src/main/java/io/flutter/plugins/JPNativePlugin.java new file mode 100644 index 0000000..61f9504 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/JPNativePlugin.java @@ -0,0 +1,96 @@ +package io.flutter.plugins; + +import android.app.Activity; +import android.content.Context; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaFormat; +import android.os.Build; + +import com.meituan.android.walle.WalleChannelReader; + +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +/// 本地插件:设备号 / 视频文件信息 / 渠道号 / H265 硬解能力 +public class JPNativePlugin implements MethodChannel.MethodCallHandler { + + private static final String CHANNEL = "com.yinse/device"; + + private final Activity activity; + + private JPNativePlugin(Activity activity) { + this.activity = activity; + } + + /// 注册 + public static void registerWith(FlutterActivity flutterActivity, FlutterEngine engine) { + MethodChannel channel = new MethodChannel(engine.getDartExecutor().getBinaryMessenger(), CHANNEL); + channel.setMethodCallHandler(new JPNativePlugin(flutterActivity)); + } + + /// 调用 + @Override + public void onMethodCall(MethodCall call, MethodChannel.Result result) { + switch (call.method) { + case "getVideoDuration": + result.success(JPFileUtils.getVideoDuration(call.argument("filePath"))); + break; + case "getVideoResolution": + result.success(JPFileUtils.getVideoResolution(call.argument("filePath"))); + break; + case "getVideoRatio": + result.success(JPFileUtils.getVideoRatio(call.argument("filePath"))); + break; + case "getVideoHeight": + result.success(JPFileUtils.getVideoHeight(call.argument("filePath"))); + break; + case "getVideoBitrate": + result.success(JPFileUtils.getVideoBitrate(call.argument("filePath"))); + break; + case "getChannel": // 获取android 渠道的东西 + result.success(getChannel(activity)); + break; + case "isH265HardwareSupported": + result.success(isHevcHardwareSupported()); + break; + default: + // 不回 result 的话 Dart 那边的 Future 永远不完成 + result.notImplemented(); + } + } + + /// 获取渠道号 + public static String getChannel(Context context) { + return WalleChannelReader.getChannel(context); + } + + /// 本机是否**硬解** H.265(HEVC)。只认硬解:软解(google/c2.android)当不支持,回落 264。 + /// 查询编解码器能力,任何异常都按不支持处理。 + private static boolean isHevcHardwareSupported() { + try { + MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + for (MediaCodecInfo info : list.getCodecInfos()) { + if (info.isEncoder() || !supportsHevc(info)) continue; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (info.isHardwareAccelerated()) return true; // API29+ 直接问硬解 + } else { + String name = info.getName().toLowerCase(); // 旧版本靠名字排除软解 + if (!name.startsWith("omx.google.") && !name.startsWith("c2.android.")) return true; + } + } + } catch (Exception e) { + return false; + } + return false; + } + + private static boolean supportsHevc(MediaCodecInfo info) { + for (String type : info.getSupportedTypes()) { + if (type.equalsIgnoreCase(MediaFormat.MIMETYPE_VIDEO_HEVC)) return true; + } + return false; + } +} diff --git a/android/app/src/main/kotlin/com/xvproject/JPApplication.kt b/android/app/src/main/kotlin/com/xvproject/JPApplication.kt new file mode 100644 index 0000000..3a46c84 --- /dev/null +++ b/android/app/src/main/kotlin/com/xvproject/JPApplication.kt @@ -0,0 +1,5 @@ +package com.xvproject + +import io.flutter.app.FlutterApplication + +class JPApplication : FlutterApplication() diff --git a/android/app/src/main/kotlin/com/xvproject/MainActivity.kt b/android/app/src/main/kotlin/com/xvproject/MainActivity.kt new file mode 100644 index 0000000..ec57d81 --- /dev/null +++ b/android/app/src/main/kotlin/com/xvproject/MainActivity.kt @@ -0,0 +1,77 @@ +package com.xvproject + +import android.annotation.SuppressLint +import android.app.ActivityManager +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugins.JPNativePlugin + +class MainActivity : FlutterActivity() { + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + JPNativePlugin.registerWith(this, flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + METHOD_CHANGE_ICON -> { + val index = call.argument(ARG_ICON_INDEX) ?: -1 + ICON_ALIASES.getOrNull(index)?.let { switchIcon("$ALIAS_PACKAGE.$it") } + result.success(true) + } + // 不回 result 的话 Dart 那边的 Future 永远不完成 + else -> result.notImplemented() + } + } + } + + /// 切换启动图标:禁用当前入口 + 启用目标 alias,再杀掉桌面进程让图标立刻刷新 + private fun switchIcon(alias: String) { + val pm = packageManager + pm.setComponentEnabledSetting( + componentName, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP + ) + pm.setComponentEnabledSetting( + ComponentName(this, alias), + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP + ) + restartLauncher(pm) + } + + /// 杀掉桌面进程,强制它重读图标 + @SuppressLint("ServiceCast") + private fun restartLauncher(pm: PackageManager) { + val am = getSystemService(ACTIVITY_SERVICE) as ActivityManager + val intent = Intent(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_HOME) + .addCategory(Intent.CATEGORY_DEFAULT) + for (info in pm.queryIntentActivities(intent, 0)) { + am.killBackgroundProcesses(info.activityInfo?.packageName ?: continue) + } + } + + companion object { + //这个跟flutter的通道名称保持一致 + private const val CHANNEL = "com.yinse/request" + + private const val METHOD_CHANGE_ICON = "changeIcon" + private const val ARG_ICON_INDEX = "iconIndex" + + /// 图标 alias 所在包名:AndroidManifest 里 activity-alias 写的是相对名(manifest package=com.xvproject), + /// 与 applicationId 不同,不能用 packageName 拼 + private const val ALIAS_PACKAGE = "com.xvproject" + + /// 下标即 Flutter 传来的 iconIndex,顺序必须与 manifest 里 activity-alias 的声明顺序一致 + private val ICON_ALIASES = listOf( + "lld", "aiyinyue", "aiyuedu", "alipay", "bilibili", "binggandazhuozhan", + "meisijiejian", "myworld", "shenxuanzhizhan", "shuixinwuxian", "texuanxiaoshuo", + "tiantianmajiang", "tuyou", "weichat", "wendao", "yinyutusujiao", + "icon_16", "icon_17", "icon_18", //清明 / 端午 / 劳动 + ) + } +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_bg.xml b/android/app/src/main/res/drawable/launch_bg.xml new file mode 100644 index 0000000..3d0315d --- /dev/null +++ b/android/app/src/main/res/drawable/launch_bg.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..dc1cfce Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_splash_bg.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_splash_bg.webp new file mode 100644 index 0000000..4b39285 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_splash_bg.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_1.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_1.webp new file mode 100644 index 0000000..98b0c1f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_1.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_10.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_10.webp new file mode 100644 index 0000000..5e3fda6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_10.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_11.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_11.webp new file mode 100644 index 0000000..810a9df Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_11.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_12.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_12.webp new file mode 100644 index 0000000..959d53d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_12.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_13.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_13.webp new file mode 100644 index 0000000..f6a6cc0 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_13.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_14.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_14.webp new file mode 100644 index 0000000..88f6569 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_14.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_15.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_15.webp new file mode 100644 index 0000000..6df2642 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_15.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_16.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_16.webp new file mode 100644 index 0000000..68c3a78 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_16.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_17.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_17.webp new file mode 100644 index 0000000..2f184df Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_17.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_18.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_18.webp new file mode 100644 index 0000000..1a67467 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_18.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_2.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_2.webp new file mode 100644 index 0000000..5a3aa85 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_2.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_3.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_3.webp new file mode 100644 index 0000000..366adf8 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_3.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_4.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_4.webp new file mode 100644 index 0000000..f711ca4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_4.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_5.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_5.webp new file mode 100644 index 0000000..0677cdf Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_5.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_6.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_6.webp new file mode 100644 index 0000000..06bcb20 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_6.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_7.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_7.webp new file mode 100644 index 0000000..ae5d1ea Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_7.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_8.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_8.webp new file mode 100644 index 0000000..816b789 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_8.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/icon_9.webp b/android/app/src/main/res/mipmap-xxhdpi/icon_9.webp new file mode 100644 index 0000000..f66aeda Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/icon_9.webp differ 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/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..e16bdd5 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/xml/totalfilepath.xml b/android/app/src/main/res/xml/totalfilepath.xml new file mode 100644 index 0000000..977055a --- /dev/null +++ b/android/app/src/main/res/xml/totalfilepath.xml @@ -0,0 +1,23 @@ + + + + + + + + + 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 b/android/build.gradle new file mode 100644 index 0000000..becd2b4 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,128 @@ +buildscript { + repositories { + google() + mavenCentral() + maven { url 'https://jitpack.io' } // 混淆 + maven { url 'https://maven.aliyun.com/nexus/content/groups/public/' } + maven { url 'https://maven.aliyun.com/nexus/content/repositories/jcenter' } + maven { url 'https://maven.aliyun.com/nexus/content/repositories/google' } + maven { url 'https://maven.aliyun.com/nexus/content/repositories/gradle-plugin' } + } + + dependencies { + classpath "com.github.CodingGay:BlackObfuscator-ASPlugin:3.9" + classpath 'com.github.Petterpx.walle:plugin:1.0.5' + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { url 'https://maven.aliyun.com/nexus/content/groups/public/' } + maven { url 'https://maven.aliyun.com/nexus/content/repositories/jcenter' } + maven { url 'https://maven.aliyun.com/nexus/content/repositories/google' } + maven { url 'https://maven.aliyun.com/nexus/content/repositories/gradle-plugin' } + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} + +def ensureAndroidNamespace = { project -> + def androidExtension = project.extensions.findByName("android") + if (androidExtension == null || !androidExtension.hasProperty("namespace") || androidExtension.namespace != null) { + return + } + + def manifestFile = project.file("src/main/AndroidManifest.xml") + if (manifestFile.exists()) { + def manifestPackage = new XmlSlurper(false, false).parse(manifestFile).@package?.text() + if (manifestPackage) { + androidExtension.namespace = manifestPackage + return + } + } + + if (project.group) { + androidExtension.namespace = project.group.toString() + } +} + +def setLibraryCompileSdk = { project -> + def androidExtension = project.extensions.findByName("android") + if (androidExtension == null) { + return + } + try { + if (androidExtension.hasProperty("compileSdk")) { + androidExtension.compileSdk = 36 + } else if (androidExtension.hasProperty("compileSdkVersion")) { + androidExtension.compileSdkVersion 36 + } + } catch (Exception ignored) { + // AGP 已锁定 compileSdk,跳过;该模块用自己声明的值即可 + } +} + +def alignAndroidJvmTargets = { project -> + def androidExtension = project.extensions.findByName("android") + if (androidExtension == null) { + return + } + + try { + if (androidExtension.hasProperty("compileOptions")) { + androidExtension.compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + } + } catch (Exception ignored) {} + + try { + if (androidExtension.hasProperty("kotlinOptions")) { + androidExtension.kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + } + } catch (Exception ignored) {} +} + +def finalizeAndroidAlignment = { project -> + if (project.plugins.hasPlugin("com.android.application") || project.plugins.hasPlugin("com.android.library")) { + ensureAndroidNamespace(project) + alignAndroidJvmTargets(project) + } + // afterEvaluate 阶段再强制一次 library 的 compileSdk,覆盖个别老插件 + // (如 flutter_custom_dialog 在自身 build.gradle 写死 compileSdkVersion 28) + // 在 plugin apply 之后声明的低版本,否则配 Java 17 编译会报需 compileSdk≥30 + if (project.plugins.hasPlugin("com.android.library")) { + setLibraryCompileSdk(project) + } +} + +subprojects { + plugins.withId("com.android.application") { + ensureAndroidNamespace(project) + // app 自己 build.gradle 已声明 compileSdk 36,这里不再重设(AGP 8.x 在 plugin apply 后会立即锁定 compileSdk) + } + plugins.withId("com.android.library") { + ensureAndroidNamespace(project) + setLibraryCompileSdk(project) + } + if (project.state.executed) { + finalizeAndroidAlignment(project) + } else { + afterEvaluate { + finalizeAndroidAlignment(project) + } + } + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..50ca1c9 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/build_release.sh b/android/build_release.sh new file mode 100755 index 0000000..8277d09 --- /dev/null +++ b/android/build_release.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# build apk +# author: @sudong +# 使用方法:./build_release.sh 文件服务 http://in.test.tsmgsoce.com/sudong1/ +./gradlew clean +rm -rf ./app/release/ +if [ $? -eq 0 ]; then + echo "gradle clean success" + ./gradlew assembleRelease + if [ $? -eq 0 ]; then + echo "gradle build success" + filename=`ls -t ./app/release|head -n1|awk '{print $0}'` + echo "filename is $filename" + cd ./app/release +# pwd +# scp -P 22889 $filename root@35.220.142.29:/data/test-apk/sudong1 + fi +fi + diff --git a/android/generatekey.sh b/android/generatekey.sh new file mode 100755 index 0000000..eeb1a0a --- /dev/null +++ b/android/generatekey.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# 生成密钥脚本 +# author: @sudong +# 使用方法:./generatekey.sh alias + +if [ $# -ne 1 ]; then + printf -- "Please input alias.\n\n" + exit +fi +echo "输入参数密钥$1\n" +keystoreName="$1.jks" + echo "$keystoreName" + echo "$1" +if [ ! -d "./keystore" ]; then + mkdir ./keystore +fi +keystoreName=`uuidgen |cut -c 1-6` + keytool -genkeypair -alias $1 -keyalg "RSA" -keysize 2048 -keypass jiahua123456 -validity 3650 -keystore ./keystore/$1.jks -storepass jiahua123456 -dname "CN='中国', OU='广东', O='广东', L='广东', ST='广东', C='中国'" + echo jiahua123456 | keytool -importkeystore -srckeystore ./keystore/$1.jks -destkeystore ./keystore/$1.jks -deststoretype pkcs12 + +if [ ! -f "./key.properties" ]; then + touch ./key.properties +fi +# shellcheck disable=SC2129 +echo "storePassword=jiahua123456" >> ./key.properties +echo "keyPassword=jiahua123456" >> ./key.properties +echo "keyAlias=$1" >> ./key.properties +echo "storeFile=./keystore/$1.jks" >> ./key.properties \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..a580ce1 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.enableJetifier=true +android.enableR8=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /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-8.12-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..b507b94 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return 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 "8.9.1" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false +} + +include ":app" diff --git a/assets/images/acg/acg_like_nor.png b/assets/images/acg/acg_like_nor.png new file mode 100644 index 0000000..b6e65a8 Binary files /dev/null and b/assets/images/acg/acg_like_nor.png differ diff --git a/assets/images/acg/acg_like_sel.png b/assets/images/acg/acg_like_sel.png new file mode 100644 index 0000000..b85ee50 Binary files /dev/null and b/assets/images/acg/acg_like_sel.png differ diff --git a/assets/images/acg/acg_menu.png b/assets/images/acg/acg_menu.png new file mode 100644 index 0000000..3dee7c0 Binary files /dev/null and b/assets/images/acg/acg_menu.png differ diff --git a/assets/images/acg/acg_narrow.png b/assets/images/acg/acg_narrow.png new file mode 100644 index 0000000..9e41fac Binary files /dev/null and b/assets/images/acg/acg_narrow.png differ diff --git a/assets/images/acg/acg_noval_back.png b/assets/images/acg/acg_noval_back.png new file mode 100644 index 0000000..765d1c4 Binary files /dev/null and b/assets/images/acg/acg_noval_back.png differ diff --git a/assets/images/acg/acg_share.png b/assets/images/acg/acg_share.png new file mode 100644 index 0000000..bcba9d2 Binary files /dev/null and b/assets/images/acg/acg_share.png differ diff --git a/assets/images/acg/cartoon_auto_play.gif b/assets/images/acg/cartoon_auto_play.gif new file mode 100644 index 0000000..9a34c32 Binary files /dev/null and b/assets/images/acg/cartoon_auto_play.gif differ diff --git a/assets/images/acg/cartoon_auto_play.png b/assets/images/acg/cartoon_auto_play.png new file mode 100644 index 0000000..2d65ddd Binary files /dev/null and b/assets/images/acg/cartoon_auto_play.png differ diff --git a/assets/images/acg/cartoon_comment.png b/assets/images/acg/cartoon_comment.png new file mode 100644 index 0000000..c69c5c3 Binary files /dev/null and b/assets/images/acg/cartoon_comment.png differ diff --git a/assets/images/acg/cartoon_menu.png b/assets/images/acg/cartoon_menu.png new file mode 100644 index 0000000..d9f4d74 Binary files /dev/null and b/assets/images/acg/cartoon_menu.png differ diff --git a/assets/images/acg/cartoon_progress.png b/assets/images/acg/cartoon_progress.png new file mode 100644 index 0000000..b8bcf8b Binary files /dev/null and b/assets/images/acg/cartoon_progress.png differ diff --git a/assets/images/acg/forward.png b/assets/images/acg/forward.png new file mode 100644 index 0000000..f66d65b Binary files /dev/null and b/assets/images/acg/forward.png differ diff --git a/assets/images/acg/list_cycle.png b/assets/images/acg/list_cycle.png new file mode 100644 index 0000000..2b43e12 Binary files /dev/null and b/assets/images/acg/list_cycle.png differ diff --git a/assets/images/acg/next.png b/assets/images/acg/next.png new file mode 100644 index 0000000..f7aa5ee Binary files /dev/null and b/assets/images/acg/next.png differ diff --git a/assets/images/acg/noval_sign.png b/assets/images/acg/noval_sign.png new file mode 100644 index 0000000..5fcf424 Binary files /dev/null and b/assets/images/acg/noval_sign.png differ diff --git a/assets/images/acg/noval_time_back.png b/assets/images/acg/noval_time_back.png new file mode 100644 index 0000000..0ace358 Binary files /dev/null and b/assets/images/acg/noval_time_back.png differ diff --git a/assets/images/acg/noval_time_forword.png b/assets/images/acg/noval_time_forword.png new file mode 100644 index 0000000..ffff16c Binary files /dev/null and b/assets/images/acg/noval_time_forword.png differ diff --git a/assets/images/acg/pause.png b/assets/images/acg/pause.png new file mode 100644 index 0000000..6586de0 Binary files /dev/null and b/assets/images/acg/pause.png differ diff --git a/assets/images/acg/play.png b/assets/images/acg/play.png new file mode 100644 index 0000000..1d4cfe5 Binary files /dev/null and b/assets/images/acg/play.png differ diff --git a/assets/images/acg/play_list.png b/assets/images/acg/play_list.png new file mode 100644 index 0000000..7aea32f Binary files /dev/null and b/assets/images/acg/play_list.png differ diff --git a/assets/images/acg/single_cycle.png b/assets/images/acg/single_cycle.png new file mode 100644 index 0000000..0c4377d Binary files /dev/null and b/assets/images/acg/single_cycle.png differ diff --git a/assets/images/acg/text_last.png b/assets/images/acg/text_last.png new file mode 100644 index 0000000..f87e309 Binary files /dev/null and b/assets/images/acg/text_last.png differ diff --git a/assets/images/acg/text_moon.png b/assets/images/acg/text_moon.png new file mode 100644 index 0000000..f55e637 Binary files /dev/null and b/assets/images/acg/text_moon.png differ diff --git a/assets/images/acg/text_next.png b/assets/images/acg/text_next.png new file mode 100644 index 0000000..a16d336 Binary files /dev/null and b/assets/images/acg/text_next.png differ diff --git a/assets/images/acg/text_play_menu.png b/assets/images/acg/text_play_menu.png new file mode 100644 index 0000000..27486da Binary files /dev/null and b/assets/images/acg/text_play_menu.png differ diff --git a/assets/images/acg/text_play_sort.png b/assets/images/acg/text_play_sort.png new file mode 100644 index 0000000..bae3100 Binary files /dev/null and b/assets/images/acg/text_play_sort.png differ diff --git a/assets/images/acg/text_sun.png b/assets/images/acg/text_sun.png new file mode 100644 index 0000000..8d835ec Binary files /dev/null and b/assets/images/acg/text_sun.png differ diff --git a/assets/images/ai/ai_address.png b/assets/images/ai/ai_address.png new file mode 100644 index 0000000..be2c6f3 Binary files /dev/null and b/assets/images/ai/ai_address.png differ diff --git a/assets/images/ai/ai_changeface_right.webp b/assets/images/ai/ai_changeface_right.webp new file mode 100644 index 0000000..058223e Binary files /dev/null and b/assets/images/ai/ai_changeface_right.webp differ diff --git a/assets/images/ai/ai_changeface_wrong_1.webp b/assets/images/ai/ai_changeface_wrong_1.webp new file mode 100644 index 0000000..b30b90a Binary files /dev/null and b/assets/images/ai/ai_changeface_wrong_1.webp differ diff --git a/assets/images/ai/ai_changeface_wrong_2.webp b/assets/images/ai/ai_changeface_wrong_2.webp new file mode 100644 index 0000000..6f96bba Binary files /dev/null and b/assets/images/ai/ai_changeface_wrong_2.webp differ diff --git a/assets/images/ai/ai_coupon_bg.webp b/assets/images/ai/ai_coupon_bg.webp new file mode 100644 index 0000000..ed6bb29 Binary files /dev/null and b/assets/images/ai/ai_coupon_bg.webp differ diff --git a/assets/images/ai/ai_draw.png b/assets/images/ai/ai_draw.png new file mode 100644 index 0000000..3eb4d06 Binary files /dev/null and b/assets/images/ai/ai_draw.png differ diff --git a/assets/images/ai/ai_draw_narrow.png b/assets/images/ai/ai_draw_narrow.png new file mode 100644 index 0000000..b75dc85 Binary files /dev/null and b/assets/images/ai/ai_draw_narrow.png differ diff --git a/assets/images/ai/ai_draw_style.png b/assets/images/ai/ai_draw_style.png new file mode 100644 index 0000000..5f9f872 Binary files /dev/null and b/assets/images/ai/ai_draw_style.png differ diff --git a/assets/images/ai/ai_draw_unselected.png b/assets/images/ai/ai_draw_unselected.png new file mode 100644 index 0000000..539215d Binary files /dev/null and b/assets/images/ai/ai_draw_unselected.png differ diff --git a/assets/images/ai/ai_girl_friend_bg.webp b/assets/images/ai/ai_girl_friend_bg.webp new file mode 100644 index 0000000..e9612bc Binary files /dev/null and b/assets/images/ai/ai_girl_friend_bg.webp differ diff --git a/assets/images/ai/ai_girl_friend_btn.webp b/assets/images/ai/ai_girl_friend_btn.webp new file mode 100644 index 0000000..762ae3c Binary files /dev/null and b/assets/images/ai/ai_girl_friend_btn.webp differ diff --git a/assets/images/ai/ai_girl_friend_top.webp b/assets/images/ai/ai_girl_friend_top.webp new file mode 100644 index 0000000..fa72e66 Binary files /dev/null and b/assets/images/ai/ai_girl_friend_top.webp differ diff --git a/assets/images/ai/ai_girl_friend_topup.webp b/assets/images/ai/ai_girl_friend_topup.webp new file mode 100644 index 0000000..36e4e8a Binary files /dev/null and b/assets/images/ai/ai_girl_friend_topup.webp differ diff --git a/assets/images/ai/ai_itv.png b/assets/images/ai/ai_itv.png new file mode 100644 index 0000000..bc4a55b Binary files /dev/null and b/assets/images/ai/ai_itv.png differ diff --git a/assets/images/ai/ai_mate_enter.png b/assets/images/ai/ai_mate_enter.png new file mode 100644 index 0000000..536e604 Binary files /dev/null and b/assets/images/ai/ai_mate_enter.png differ diff --git a/assets/images/ai/ai_narrow.png b/assets/images/ai/ai_narrow.png new file mode 100644 index 0000000..6866cf6 Binary files /dev/null and b/assets/images/ai/ai_narrow.png differ diff --git a/assets/images/ai/ai_novel_enter.png b/assets/images/ai/ai_novel_enter.png new file mode 100644 index 0000000..e203360 Binary files /dev/null and b/assets/images/ai/ai_novel_enter.png differ diff --git a/assets/images/ai/ai_pic_face.png b/assets/images/ai/ai_pic_face.png new file mode 100644 index 0000000..1a129e1 Binary files /dev/null and b/assets/images/ai/ai_pic_face.png differ diff --git a/assets/images/ai/ai_record_add.webp b/assets/images/ai/ai_record_add.webp new file mode 100644 index 0000000..480fa36 Binary files /dev/null and b/assets/images/ai/ai_record_add.webp differ diff --git a/assets/images/ai/ai_record_delete.png b/assets/images/ai/ai_record_delete.png new file mode 100644 index 0000000..51f04e7 Binary files /dev/null and b/assets/images/ai/ai_record_delete.png differ diff --git a/assets/images/ai/ai_record_equals.png b/assets/images/ai/ai_record_equals.png new file mode 100644 index 0000000..c04fba4 Binary files /dev/null and b/assets/images/ai/ai_record_equals.png differ diff --git a/assets/images/ai/ai_record_failed.webp b/assets/images/ai/ai_record_failed.webp new file mode 100644 index 0000000..a8288ca Binary files /dev/null and b/assets/images/ai/ai_record_failed.webp differ diff --git a/assets/images/ai/ai_record_org.png b/assets/images/ai/ai_record_org.png new file mode 100644 index 0000000..c8d959a Binary files /dev/null and b/assets/images/ai/ai_record_org.png differ diff --git a/assets/images/ai/ai_video_face.png b/assets/images/ai/ai_video_face.png new file mode 100644 index 0000000..8490b74 Binary files /dev/null and b/assets/images/ai/ai_video_face.png differ diff --git a/assets/images/ai/icon_ai_download.png b/assets/images/ai/icon_ai_download.png new file mode 100644 index 0000000..6f83b84 Binary files /dev/null and b/assets/images/ai/icon_ai_download.png differ diff --git a/assets/images/ai/make_the_same.png b/assets/images/ai/make_the_same.png new file mode 100644 index 0000000..49edbcb Binary files /dev/null and b/assets/images/ai/make_the_same.png differ diff --git a/assets/images/ai/paly_btn.png b/assets/images/ai/paly_btn.png new file mode 100644 index 0000000..6fd8ce3 Binary files /dev/null and b/assets/images/ai/paly_btn.png differ diff --git a/assets/images/app_icon/icon_1.webp b/assets/images/app_icon/icon_1.webp new file mode 100644 index 0000000..f866bb9 Binary files /dev/null and b/assets/images/app_icon/icon_1.webp differ diff --git a/assets/images/app_icon/icon_10.webp b/assets/images/app_icon/icon_10.webp new file mode 100644 index 0000000..3b34124 Binary files /dev/null and b/assets/images/app_icon/icon_10.webp differ diff --git a/assets/images/app_icon/icon_11.webp b/assets/images/app_icon/icon_11.webp new file mode 100644 index 0000000..53e3c8e Binary files /dev/null and b/assets/images/app_icon/icon_11.webp differ diff --git a/assets/images/app_icon/icon_12.webp b/assets/images/app_icon/icon_12.webp new file mode 100644 index 0000000..b9e6590 Binary files /dev/null and b/assets/images/app_icon/icon_12.webp differ diff --git a/assets/images/app_icon/icon_13.webp b/assets/images/app_icon/icon_13.webp new file mode 100644 index 0000000..3aebe99 Binary files /dev/null and b/assets/images/app_icon/icon_13.webp differ diff --git a/assets/images/app_icon/icon_14.webp b/assets/images/app_icon/icon_14.webp new file mode 100644 index 0000000..07d8560 Binary files /dev/null and b/assets/images/app_icon/icon_14.webp differ diff --git a/assets/images/app_icon/icon_15.webp b/assets/images/app_icon/icon_15.webp new file mode 100644 index 0000000..a0d2353 Binary files /dev/null and b/assets/images/app_icon/icon_15.webp differ diff --git a/assets/images/app_icon/icon_2.webp b/assets/images/app_icon/icon_2.webp new file mode 100644 index 0000000..1656a29 Binary files /dev/null and b/assets/images/app_icon/icon_2.webp differ diff --git a/assets/images/app_icon/icon_3.webp b/assets/images/app_icon/icon_3.webp new file mode 100644 index 0000000..497d307 Binary files /dev/null and b/assets/images/app_icon/icon_3.webp differ diff --git a/assets/images/app_icon/icon_4.webp b/assets/images/app_icon/icon_4.webp new file mode 100644 index 0000000..37f6281 Binary files /dev/null and b/assets/images/app_icon/icon_4.webp differ diff --git a/assets/images/app_icon/icon_5.webp b/assets/images/app_icon/icon_5.webp new file mode 100644 index 0000000..52f2194 Binary files /dev/null and b/assets/images/app_icon/icon_5.webp differ diff --git a/assets/images/app_icon/icon_6.webp b/assets/images/app_icon/icon_6.webp new file mode 100644 index 0000000..ef40822 Binary files /dev/null and b/assets/images/app_icon/icon_6.webp differ diff --git a/assets/images/app_icon/icon_7.webp b/assets/images/app_icon/icon_7.webp new file mode 100644 index 0000000..799acb7 Binary files /dev/null and b/assets/images/app_icon/icon_7.webp differ diff --git a/assets/images/app_icon/icon_8.webp b/assets/images/app_icon/icon_8.webp new file mode 100644 index 0000000..bf67ebc Binary files /dev/null and b/assets/images/app_icon/icon_8.webp differ diff --git a/assets/images/app_icon/icon_9.webp b/assets/images/app_icon/icon_9.webp new file mode 100644 index 0000000..6b0b9b3 Binary files /dev/null and b/assets/images/app_icon/icon_9.webp differ diff --git a/assets/images/comment/arrow_grey.webp b/assets/images/comment/arrow_grey.webp new file mode 100644 index 0000000..7a9b51b Binary files /dev/null and b/assets/images/comment/arrow_grey.webp differ diff --git a/assets/images/comment/god_comment.webp b/assets/images/comment/god_comment.webp new file mode 100644 index 0000000..c8bfd56 Binary files /dev/null and b/assets/images/comment/god_comment.webp differ diff --git a/assets/images/comment/ic_send.webp b/assets/images/comment/ic_send.webp new file mode 100644 index 0000000..ad8d508 Binary files /dev/null and b/assets/images/comment/ic_send.webp differ diff --git a/assets/images/comment/icon_pic.webp b/assets/images/comment/icon_pic.webp new file mode 100644 index 0000000..652eab4 Binary files /dev/null and b/assets/images/comment/icon_pic.webp differ diff --git a/assets/images/comment/like_red.webp b/assets/images/comment/like_red.webp new file mode 100644 index 0000000..a2551ec Binary files /dev/null and b/assets/images/comment/like_red.webp differ diff --git a/assets/images/comment/like_white.webp b/assets/images/comment/like_white.webp new file mode 100644 index 0000000..0772797 Binary files /dev/null and b/assets/images/comment/like_white.webp differ diff --git a/assets/images/comment/msg_icon.png b/assets/images/comment/msg_icon.png new file mode 100644 index 0000000..f562773 Binary files /dev/null and b/assets/images/comment/msg_icon.png differ diff --git a/assets/images/comment/vip_v.webp b/assets/images/comment/vip_v.webp new file mode 100644 index 0000000..4f3ee40 Binary files /dev/null and b/assets/images/comment/vip_v.webp differ diff --git a/assets/images/common/ad_close.png b/assets/images/common/ad_close.png new file mode 100644 index 0000000..751db44 Binary files /dev/null and b/assets/images/common/ad_close.png differ diff --git a/assets/images/common/add_red.png b/assets/images/common/add_red.png new file mode 100644 index 0000000..fb9537a Binary files /dev/null and b/assets/images/common/add_red.png differ diff --git a/assets/images/common/app_main_logo.webp b/assets/images/common/app_main_logo.webp new file mode 100644 index 0000000..739adcf Binary files /dev/null and b/assets/images/common/app_main_logo.webp differ diff --git a/assets/images/common/arrow_down.png b/assets/images/common/arrow_down.png new file mode 100644 index 0000000..fd00431 Binary files /dev/null and b/assets/images/common/arrow_down.png differ diff --git a/assets/images/common/arrow_right_grey.webp b/assets/images/common/arrow_right_grey.webp new file mode 100644 index 0000000..a28b1b7 Binary files /dev/null and b/assets/images/common/arrow_right_grey.webp differ diff --git a/assets/images/common/back_circle.png b/assets/images/common/back_circle.png new file mode 100644 index 0000000..72b72c7 Binary files /dev/null and b/assets/images/common/back_circle.png differ diff --git a/assets/images/common/back_circle_grey.png b/assets/images/common/back_circle_grey.png new file mode 100644 index 0000000..5196130 Binary files /dev/null and b/assets/images/common/back_circle_grey.png differ diff --git a/assets/images/common/check.webp b/assets/images/common/check.webp new file mode 100644 index 0000000..3160357 Binary files /dev/null and b/assets/images/common/check.webp differ diff --git a/assets/images/common/chevron_down.webp b/assets/images/common/chevron_down.webp new file mode 100644 index 0000000..8e464fa Binary files /dev/null and b/assets/images/common/chevron_down.webp differ diff --git a/assets/images/common/close_button.png b/assets/images/common/close_button.png new file mode 100644 index 0000000..14f4e49 Binary files /dev/null and b/assets/images/common/close_button.png differ diff --git a/assets/images/common/close_grey.png b/assets/images/common/close_grey.png new file mode 100644 index 0000000..99070b9 Binary files /dev/null and b/assets/images/common/close_grey.png differ diff --git a/assets/images/common/coin.webp b/assets/images/common/coin.webp new file mode 100644 index 0000000..2ccba48 Binary files /dev/null and b/assets/images/common/coin.webp differ diff --git a/assets/images/common/collect_empty.png b/assets/images/common/collect_empty.png new file mode 100644 index 0000000..fb5f00c Binary files /dev/null and b/assets/images/common/collect_empty.png differ diff --git a/assets/images/common/collect_grey.png b/assets/images/common/collect_grey.png new file mode 100644 index 0000000..55cf835 Binary files /dev/null and b/assets/images/common/collect_grey.png differ diff --git a/assets/images/common/collect_path.png b/assets/images/common/collect_path.png new file mode 100644 index 0000000..7944961 Binary files /dev/null and b/assets/images/common/collect_path.png differ diff --git a/assets/images/common/collect_red.png b/assets/images/common/collect_red.png new file mode 100644 index 0000000..c1467f5 Binary files /dev/null and b/assets/images/common/collect_red.png differ diff --git a/assets/images/common/collect_white.png b/assets/images/common/collect_white.png new file mode 100644 index 0000000..3669888 Binary files /dev/null and b/assets/images/common/collect_white.png differ diff --git a/assets/images/common/common_back.png b/assets/images/common/common_back.png new file mode 100644 index 0000000..0a72975 Binary files /dev/null and b/assets/images/common/common_back.png differ diff --git a/assets/images/common/common_back_new.png b/assets/images/common/common_back_new.png new file mode 100644 index 0000000..e9788a6 Binary files /dev/null and b/assets/images/common/common_back_new.png differ diff --git a/assets/images/common/eye_grey.webp b/assets/images/common/eye_grey.webp new file mode 100644 index 0000000..130f5a2 Binary files /dev/null and b/assets/images/common/eye_grey.webp differ diff --git a/assets/images/common/eye_white.webp b/assets/images/common/eye_white.webp new file mode 100644 index 0000000..9ffc681 Binary files /dev/null and b/assets/images/common/eye_white.webp differ diff --git a/assets/images/common/ic_launcher.webp b/assets/images/common/ic_launcher.webp new file mode 100644 index 0000000..dc1cfce Binary files /dev/null and b/assets/images/common/ic_launcher.webp differ diff --git a/assets/images/common/ic_nodata.webp b/assets/images/common/ic_nodata.webp new file mode 100644 index 0000000..a0a9373 Binary files /dev/null and b/assets/images/common/ic_nodata.webp differ diff --git a/assets/images/common/icon_broadcast.png b/assets/images/common/icon_broadcast.png new file mode 100644 index 0000000..b24fd5b Binary files /dev/null and b/assets/images/common/icon_broadcast.png differ diff --git a/assets/images/common/like_red.png b/assets/images/common/like_red.png new file mode 100644 index 0000000..0d7faee Binary files /dev/null and b/assets/images/common/like_red.png differ diff --git a/assets/images/common/like_white.png b/assets/images/common/like_white.png new file mode 100644 index 0000000..2d557c3 Binary files /dev/null and b/assets/images/common/like_white.png differ diff --git a/assets/images/common/lucky_lottery.webp b/assets/images/common/lucky_lottery.webp new file mode 100644 index 0000000..cf9423b Binary files /dev/null and b/assets/images/common/lucky_lottery.webp differ diff --git a/assets/images/common/official_icon.png b/assets/images/common/official_icon.png new file mode 100644 index 0000000..aff73d2 Binary files /dev/null and b/assets/images/common/official_icon.png differ diff --git a/assets/images/common/place_holder_logo.webp b/assets/images/common/place_holder_logo.webp new file mode 100644 index 0000000..2745bd3 Binary files /dev/null and b/assets/images/common/place_holder_logo.webp differ diff --git a/assets/images/common/radio_sel.png b/assets/images/common/radio_sel.png new file mode 100644 index 0000000..c0d4158 Binary files /dev/null and b/assets/images/common/radio_sel.png differ diff --git a/assets/images/common/search_close.png b/assets/images/common/search_close.png new file mode 100644 index 0000000..53689ba Binary files /dev/null and b/assets/images/common/search_close.png differ diff --git a/assets/images/common/search_icon.png b/assets/images/common/search_icon.png new file mode 100644 index 0000000..1a3c42f Binary files /dev/null and b/assets/images/common/search_icon.png differ diff --git a/assets/images/common/share_grey.png b/assets/images/common/share_grey.png new file mode 100644 index 0000000..e7b2bb7 Binary files /dev/null and b/assets/images/common/share_grey.png differ diff --git a/assets/images/community/add_grey.png b/assets/images/community/add_grey.png new file mode 100644 index 0000000..73754f2 Binary files /dev/null and b/assets/images/community/add_grey.png differ diff --git a/assets/images/community/community_chatgroup_sel.png b/assets/images/community/community_chatgroup_sel.png new file mode 100644 index 0000000..e4ca761 Binary files /dev/null and b/assets/images/community/community_chatgroup_sel.png differ diff --git a/assets/images/community/community_coin.webp b/assets/images/community/community_coin.webp new file mode 100644 index 0000000..800d117 Binary files /dev/null and b/assets/images/community/community_coin.webp differ diff --git a/assets/images/community/community_main_sel.png b/assets/images/community/community_main_sel.png new file mode 100644 index 0000000..7fabdfd Binary files /dev/null and b/assets/images/community/community_main_sel.png differ diff --git a/assets/images/community/community_noval_sel.png b/assets/images/community/community_noval_sel.png new file mode 100644 index 0000000..93ac3d0 Binary files /dev/null and b/assets/images/community/community_noval_sel.png differ diff --git a/assets/images/community/community_pic_sel.png b/assets/images/community/community_pic_sel.png new file mode 100644 index 0000000..0d62a26 Binary files /dev/null and b/assets/images/community/community_pic_sel.png differ diff --git a/assets/images/community/community_sign.webp b/assets/images/community/community_sign.webp new file mode 100644 index 0000000..76fa079 Binary files /dev/null and b/assets/images/community/community_sign.webp differ diff --git a/assets/images/community/community_thumb_sel.png b/assets/images/community/community_thumb_sel.png new file mode 100644 index 0000000..33386fe Binary files /dev/null and b/assets/images/community/community_thumb_sel.png differ diff --git a/assets/images/community/count_msg.png b/assets/images/community/count_msg.png new file mode 100644 index 0000000..61c161a Binary files /dev/null and b/assets/images/community/count_msg.png differ diff --git a/assets/images/community/count_msg_gray.png b/assets/images/community/count_msg_gray.png new file mode 100644 index 0000000..acd863e Binary files /dev/null and b/assets/images/community/count_msg_gray.png differ diff --git a/assets/images/community/hot_search.png b/assets/images/community/hot_search.png new file mode 100644 index 0000000..2073369 Binary files /dev/null and b/assets/images/community/hot_search.png differ diff --git a/assets/images/community/icon_pic.png b/assets/images/community/icon_pic.png new file mode 100644 index 0000000..56c4c4e Binary files /dev/null and b/assets/images/community/icon_pic.png differ diff --git a/assets/images/community/like_count.png b/assets/images/community/like_count.png new file mode 100644 index 0000000..4627d0b Binary files /dev/null and b/assets/images/community/like_count.png differ diff --git a/assets/images/community/like_empty.png b/assets/images/community/like_empty.png new file mode 100644 index 0000000..e33eba4 Binary files /dev/null and b/assets/images/community/like_empty.png differ diff --git a/assets/images/community/like_empty_gray.png b/assets/images/community/like_empty_gray.png new file mode 100644 index 0000000..f1e3a33 Binary files /dev/null and b/assets/images/community/like_empty_gray.png differ diff --git a/assets/images/community/link_copy_icon.webp b/assets/images/community/link_copy_icon.webp new file mode 100644 index 0000000..30edd5b Binary files /dev/null and b/assets/images/community/link_copy_icon.webp differ diff --git a/assets/images/community/locked_white.png b/assets/images/community/locked_white.png new file mode 100644 index 0000000..dfb9fbc Binary files /dev/null and b/assets/images/community/locked_white.png differ diff --git a/assets/images/community/play_icon.png b/assets/images/community/play_icon.png new file mode 100644 index 0000000..7ede1d6 Binary files /dev/null and b/assets/images/community/play_icon.png differ diff --git a/assets/images/community/post_detail_coin.png b/assets/images/community/post_detail_coin.png new file mode 100644 index 0000000..8aa7398 Binary files /dev/null and b/assets/images/community/post_detail_coin.png differ diff --git a/assets/images/community/publish_img.webp b/assets/images/community/publish_img.webp new file mode 100644 index 0000000..656fe76 Binary files /dev/null and b/assets/images/community/publish_img.webp differ diff --git a/assets/images/community/publish_img_text.webp b/assets/images/community/publish_img_text.webp new file mode 100644 index 0000000..64afb7f Binary files /dev/null and b/assets/images/community/publish_img_text.webp differ diff --git a/assets/images/community/publish_video.webp b/assets/images/community/publish_video.webp new file mode 100644 index 0000000..b532d46 Binary files /dev/null and b/assets/images/community/publish_video.webp differ diff --git a/assets/images/community/tuji_icon.webp b/assets/images/community/tuji_icon.webp new file mode 100644 index 0000000..e3b0793 Binary files /dev/null and b/assets/images/community/tuji_icon.webp differ diff --git a/assets/images/community/view_count.png b/assets/images/community/view_count.png new file mode 100644 index 0000000..a3b7137 Binary files /dev/null and b/assets/images/community/view_count.png differ diff --git a/assets/images/home/aw_bg.webp b/assets/images/home/aw_bg.webp new file mode 100644 index 0000000..5830c6e Binary files /dev/null and b/assets/images/home/aw_bg.webp differ diff --git a/assets/images/home/aw_button.webp b/assets/images/home/aw_button.webp new file mode 100644 index 0000000..5826997 Binary files /dev/null and b/assets/images/home/aw_button.webp differ diff --git a/assets/images/home/aw_tag.webp b/assets/images/home/aw_tag.webp new file mode 100644 index 0000000..38c5103 Binary files /dev/null and b/assets/images/home/aw_tag.webp differ diff --git a/assets/images/home/aw_warning.webp b/assets/images/home/aw_warning.webp new file mode 100644 index 0000000..a03f0dc Binary files /dev/null and b/assets/images/home/aw_warning.webp differ diff --git a/assets/images/home/collect_start.webp b/assets/images/home/collect_start.webp new file mode 100644 index 0000000..955e503 Binary files /dev/null and b/assets/images/home/collect_start.webp differ diff --git a/assets/images/home/drama_tip.webp b/assets/images/home/drama_tip.webp new file mode 100644 index 0000000..f29e8a6 Binary files /dev/null and b/assets/images/home/drama_tip.webp differ diff --git a/assets/images/home/grid_style.webp b/assets/images/home/grid_style.webp new file mode 100644 index 0000000..1128dd2 Binary files /dev/null and b/assets/images/home/grid_style.webp differ diff --git a/assets/images/home/history_delete.webp b/assets/images/home/history_delete.webp new file mode 100644 index 0000000..25a1681 Binary files /dev/null and b/assets/images/home/history_delete.webp differ diff --git a/assets/images/home/home_0_0.png b/assets/images/home/home_0_0.png new file mode 100644 index 0000000..a8ebc27 Binary files /dev/null and b/assets/images/home/home_0_0.png differ diff --git a/assets/images/home/home_0_1.png b/assets/images/home/home_0_1.png new file mode 100644 index 0000000..c4c1202 Binary files /dev/null and b/assets/images/home/home_0_1.png differ diff --git a/assets/images/home/home_1_0.png b/assets/images/home/home_1_0.png new file mode 100644 index 0000000..61f885e Binary files /dev/null and b/assets/images/home/home_1_0.png differ diff --git a/assets/images/home/home_1_1.png b/assets/images/home/home_1_1.png new file mode 100644 index 0000000..949d6d6 Binary files /dev/null and b/assets/images/home/home_1_1.png differ diff --git a/assets/images/home/home_2_0.png b/assets/images/home/home_2_0.png new file mode 100644 index 0000000..42133d1 Binary files /dev/null and b/assets/images/home/home_2_0.png differ diff --git a/assets/images/home/home_2_1.png b/assets/images/home/home_2_1.png new file mode 100644 index 0000000..f68865b Binary files /dev/null and b/assets/images/home/home_2_1.png differ diff --git a/assets/images/home/home_3_0.png b/assets/images/home/home_3_0.png new file mode 100644 index 0000000..b0084b7 Binary files /dev/null and b/assets/images/home/home_3_0.png differ diff --git a/assets/images/home/home_3_1.png b/assets/images/home/home_3_1.png new file mode 100644 index 0000000..ac98501 Binary files /dev/null and b/assets/images/home/home_3_1.png differ diff --git a/assets/images/home/home_4_0.png b/assets/images/home/home_4_0.png new file mode 100644 index 0000000..f0211ac Binary files /dev/null and b/assets/images/home/home_4_0.png differ diff --git a/assets/images/home/home_4_1.png b/assets/images/home/home_4_1.png new file mode 100644 index 0000000..61858ba Binary files /dev/null and b/assets/images/home/home_4_1.png differ diff --git a/assets/images/home/home_5_0.png b/assets/images/home/home_5_0.png new file mode 100644 index 0000000..57d5980 Binary files /dev/null and b/assets/images/home/home_5_0.png differ diff --git a/assets/images/home/home_5_1.png b/assets/images/home/home_5_1.png new file mode 100644 index 0000000..4f9cff1 Binary files /dev/null and b/assets/images/home/home_5_1.png differ diff --git a/assets/images/home/home_hot.webp b/assets/images/home/home_hot.webp new file mode 100644 index 0000000..2b0bb4b Binary files /dev/null and b/assets/images/home/home_hot.webp differ diff --git a/assets/images/home/jump_to_top.png b/assets/images/home/jump_to_top.png new file mode 100644 index 0000000..6006d04 Binary files /dev/null and b/assets/images/home/jump_to_top.png differ diff --git a/assets/images/home/libary_search.webp b/assets/images/home/libary_search.webp new file mode 100644 index 0000000..0806188 Binary files /dev/null and b/assets/images/home/libary_search.webp differ diff --git a/assets/images/home/list_style.webp b/assets/images/home/list_style.webp new file mode 100644 index 0000000..ad6988f Binary files /dev/null and b/assets/images/home/list_style.webp differ diff --git a/assets/images/home/publish.png b/assets/images/home/publish.png new file mode 100644 index 0000000..55d7a0f Binary files /dev/null and b/assets/images/home/publish.png differ diff --git a/assets/images/home/rank_crown.webp b/assets/images/home/rank_crown.webp new file mode 100644 index 0000000..92b034f Binary files /dev/null and b/assets/images/home/rank_crown.webp differ diff --git a/assets/images/home/search_close.webp b/assets/images/home/search_close.webp new file mode 100644 index 0000000..0b3fcdd Binary files /dev/null and b/assets/images/home/search_close.webp differ diff --git a/assets/images/launch/ic_splash_bg.webp b/assets/images/launch/ic_splash_bg.webp new file mode 100644 index 0000000..4b39285 Binary files /dev/null and b/assets/images/launch/ic_splash_bg.webp differ diff --git a/assets/images/live_images/cp_1.webp b/assets/images/live_images/cp_1.webp new file mode 100644 index 0000000..d7dd8f5 Binary files /dev/null and b/assets/images/live_images/cp_1.webp differ diff --git a/assets/images/live_images/cp_2.webp b/assets/images/live_images/cp_2.webp new file mode 100644 index 0000000..ad66fd0 Binary files /dev/null and b/assets/images/live_images/cp_2.webp differ diff --git a/assets/images/live_images/cp_3.webp b/assets/images/live_images/cp_3.webp new file mode 100644 index 0000000..af6f847 Binary files /dev/null and b/assets/images/live_images/cp_3.webp differ diff --git a/assets/images/live_images/four_grid_bg.webp b/assets/images/live_images/four_grid_bg.webp new file mode 100644 index 0000000..52c2b81 Binary files /dev/null and b/assets/images/live_images/four_grid_bg.webp differ diff --git a/assets/images/live_images/live_charge.webp b/assets/images/live_images/live_charge.webp new file mode 100644 index 0000000..da32b1f Binary files /dev/null and b/assets/images/live_images/live_charge.webp differ diff --git a/assets/images/live_images/live_main_bg.webp b/assets/images/live_images/live_main_bg.webp new file mode 100644 index 0000000..a781061 Binary files /dev/null and b/assets/images/live_images/live_main_bg.webp differ diff --git a/assets/images/live_images/live_main_bg2.webp b/assets/images/live_images/live_main_bg2.webp new file mode 100644 index 0000000..438de13 Binary files /dev/null and b/assets/images/live_images/live_main_bg2.webp differ diff --git a/assets/images/live_images/live_section_bg_1.webp b/assets/images/live_images/live_section_bg_1.webp new file mode 100644 index 0000000..ef2add8 Binary files /dev/null and b/assets/images/live_images/live_section_bg_1.webp differ diff --git a/assets/images/live_images/live_section_bg_2.webp b/assets/images/live_images/live_section_bg_2.webp new file mode 100644 index 0000000..be9c856 Binary files /dev/null and b/assets/images/live_images/live_section_bg_2.webp differ diff --git a/assets/images/live_images/live_section_bg_3.webp b/assets/images/live_images/live_section_bg_3.webp new file mode 100644 index 0000000..0c79dd5 Binary files /dev/null and b/assets/images/live_images/live_section_bg_3.webp differ diff --git a/assets/images/live_images/live_section_bg_4.webp b/assets/images/live_images/live_section_bg_4.webp new file mode 100644 index 0000000..a6e4268 Binary files /dev/null and b/assets/images/live_images/live_section_bg_4.webp differ diff --git a/assets/images/live_images/live_share.png b/assets/images/live_images/live_share.png new file mode 100644 index 0000000..fcdc79c Binary files /dev/null and b/assets/images/live_images/live_share.png differ diff --git a/assets/images/live_images/live_six_item_bg.webp b/assets/images/live_images/live_six_item_bg.webp new file mode 100644 index 0000000..108f902 Binary files /dev/null and b/assets/images/live_images/live_six_item_bg.webp differ diff --git a/assets/images/live_images/nine_grid_bg.webp b/assets/images/live_images/nine_grid_bg.webp new file mode 100644 index 0000000..1fd9949 Binary files /dev/null and b/assets/images/live_images/nine_grid_bg.webp differ diff --git a/assets/images/live_images/six_grid_bg.webp b/assets/images/live_images/six_grid_bg.webp new file mode 100644 index 0000000..189ebd0 Binary files /dev/null and b/assets/images/live_images/six_grid_bg.webp differ diff --git a/assets/images/live_images/six_round_bg.webp b/assets/images/live_images/six_round_bg.webp new file mode 100644 index 0000000..bd77f8e Binary files /dev/null and b/assets/images/live_images/six_round_bg.webp differ diff --git a/assets/images/mine/add_equal.png b/assets/images/mine/add_equal.png new file mode 100644 index 0000000..36d205e Binary files /dev/null and b/assets/images/mine/add_equal.png differ diff --git a/assets/images/mine/area_discount_bg.webp b/assets/images/mine/area_discount_bg.webp new file mode 100644 index 0000000..9304790 Binary files /dev/null and b/assets/images/mine/area_discount_bg.webp differ diff --git a/assets/images/mine/check_in_coin.webp b/assets/images/mine/check_in_coin.webp new file mode 100644 index 0000000..4945ade Binary files /dev/null and b/assets/images/mine/check_in_coin.webp differ diff --git a/assets/images/mine/checked_in_coin.webp b/assets/images/mine/checked_in_coin.webp new file mode 100644 index 0000000..4bafbf9 Binary files /dev/null and b/assets/images/mine/checked_in_coin.webp differ diff --git a/assets/images/mine/code_bg.webp b/assets/images/mine/code_bg.webp new file mode 100644 index 0000000..c1c8284 Binary files /dev/null and b/assets/images/mine/code_bg.webp differ diff --git a/assets/images/mine/coin_icon.webp b/assets/images/mine/coin_icon.webp new file mode 100644 index 0000000..acb58b9 Binary files /dev/null and b/assets/images/mine/coin_icon.webp differ diff --git a/assets/images/mine/error.webp b/assets/images/mine/error.webp new file mode 100644 index 0000000..a67f571 Binary files /dev/null and b/assets/images/mine/error.webp differ diff --git a/assets/images/mine/ic_add2.png b/assets/images/mine/ic_add2.png new file mode 100644 index 0000000..6065b34 Binary files /dev/null and b/assets/images/mine/ic_add2.png differ diff --git a/assets/images/mine/ic_alipay.png b/assets/images/mine/ic_alipay.png new file mode 100644 index 0000000..a1cd156 Binary files /dev/null and b/assets/images/mine/ic_alipay.png differ diff --git a/assets/images/mine/ic_coin.webp b/assets/images/mine/ic_coin.webp new file mode 100644 index 0000000..0845f90 Binary files /dev/null and b/assets/images/mine/ic_coin.webp differ diff --git a/assets/images/mine/ic_sign_expired.webp b/assets/images/mine/ic_sign_expired.webp new file mode 100644 index 0000000..4ca0176 Binary files /dev/null and b/assets/images/mine/ic_sign_expired.webp differ diff --git a/assets/images/mine/ic_sign_indicator.webp b/assets/images/mine/ic_sign_indicator.webp new file mode 100644 index 0000000..77758d2 Binary files /dev/null and b/assets/images/mine/ic_sign_indicator.webp differ diff --git a/assets/images/mine/ic_sign_lock.png b/assets/images/mine/ic_sign_lock.png new file mode 100644 index 0000000..0a5af65 Binary files /dev/null and b/assets/images/mine/ic_sign_lock.png differ diff --git a/assets/images/mine/ic_union.png b/assets/images/mine/ic_union.png new file mode 100644 index 0000000..6994728 Binary files /dev/null and b/assets/images/mine/ic_union.png differ diff --git a/assets/images/mine/ic_usdt.png b/assets/images/mine/ic_usdt.png new file mode 100644 index 0000000..67038cd Binary files /dev/null and b/assets/images/mine/ic_usdt.png differ diff --git a/assets/images/mine/ic_vip_enter.png b/assets/images/mine/ic_vip_enter.png new file mode 100644 index 0000000..8ab7bf5 Binary files /dev/null and b/assets/images/mine/ic_vip_enter.png differ diff --git a/assets/images/mine/ic_wechat.png b/assets/images/mine/ic_wechat.png new file mode 100644 index 0000000..e517b93 Binary files /dev/null and b/assets/images/mine/ic_wechat.png differ diff --git a/assets/images/mine/icon_copy.png b/assets/images/mine/icon_copy.png new file mode 100644 index 0000000..757896e Binary files /dev/null and b/assets/images/mine/icon_copy.png differ diff --git a/assets/images/mine/icon_resign_bg.webp b/assets/images/mine/icon_resign_bg.webp new file mode 100644 index 0000000..9d0fb70 Binary files /dev/null and b/assets/images/mine/icon_resign_bg.webp differ diff --git a/assets/images/mine/icon_resign_close.png b/assets/images/mine/icon_resign_close.png new file mode 100644 index 0000000..0abb541 Binary files /dev/null and b/assets/images/mine/icon_resign_close.png differ diff --git a/assets/images/mine/icon_sign_click.webp b/assets/images/mine/icon_sign_click.webp new file mode 100644 index 0000000..d056efb Binary files /dev/null and b/assets/images/mine/icon_sign_click.webp differ diff --git a/assets/images/mine/invite_steps.webp b/assets/images/mine/invite_steps.webp new file mode 100644 index 0000000..c4507db Binary files /dev/null and b/assets/images/mine/invite_steps.webp differ diff --git a/assets/images/mine/mine_ai_enter.webp b/assets/images/mine/mine_ai_enter.webp new file mode 100644 index 0000000..0916c7f Binary files /dev/null and b/assets/images/mine/mine_ai_enter.webp differ diff --git a/assets/images/mine/mine_bg.webp b/assets/images/mine/mine_bg.webp new file mode 100644 index 0000000..9c99e7f Binary files /dev/null and b/assets/images/mine/mine_bg.webp differ diff --git a/assets/images/mine/mine_col_delete.png b/assets/images/mine/mine_col_delete.png new file mode 100644 index 0000000..19a55a0 Binary files /dev/null and b/assets/images/mine/mine_col_delete.png differ diff --git a/assets/images/mine/mine_collect.png b/assets/images/mine/mine_collect.png new file mode 100644 index 0000000..6c4da43 Binary files /dev/null and b/assets/images/mine/mine_collect.png differ diff --git a/assets/images/mine/mine_copy.png b/assets/images/mine/mine_copy.png new file mode 100644 index 0000000..b660365 Binary files /dev/null and b/assets/images/mine/mine_copy.png differ diff --git a/assets/images/mine/mine_follow.png b/assets/images/mine/mine_follow.png new file mode 100644 index 0000000..e03800b Binary files /dev/null and b/assets/images/mine/mine_follow.png differ diff --git a/assets/images/mine/mine_gold_enter.webp b/assets/images/mine/mine_gold_enter.webp new file mode 100644 index 0000000..84f3681 Binary files /dev/null and b/assets/images/mine/mine_gold_enter.webp differ diff --git a/assets/images/mine/mine_id.png b/assets/images/mine/mine_id.png new file mode 100644 index 0000000..e6eede8 Binary files /dev/null and b/assets/images/mine/mine_id.png differ diff --git a/assets/images/mine/mine_lock_password.png b/assets/images/mine/mine_lock_password.png new file mode 100644 index 0000000..c3d95af Binary files /dev/null and b/assets/images/mine/mine_lock_password.png differ diff --git a/assets/images/mine/mine_menu_1.png b/assets/images/mine/mine_menu_1.png new file mode 100644 index 0000000..e32ef97 Binary files /dev/null and b/assets/images/mine/mine_menu_1.png differ diff --git a/assets/images/mine/mine_menu_2.png b/assets/images/mine/mine_menu_2.png new file mode 100644 index 0000000..e42f5b9 Binary files /dev/null and b/assets/images/mine/mine_menu_2.png differ diff --git a/assets/images/mine/mine_menu_3.png b/assets/images/mine/mine_menu_3.png new file mode 100644 index 0000000..9095243 Binary files /dev/null and b/assets/images/mine/mine_menu_3.png differ diff --git a/assets/images/mine/mine_menu_4.png b/assets/images/mine/mine_menu_4.png new file mode 100644 index 0000000..fa91cc6 Binary files /dev/null and b/assets/images/mine/mine_menu_4.png differ diff --git a/assets/images/mine/mine_menu_5.png b/assets/images/mine/mine_menu_5.png new file mode 100644 index 0000000..dbd2706 Binary files /dev/null and b/assets/images/mine/mine_menu_5.png differ diff --git a/assets/images/mine/mine_menu_6.png b/assets/images/mine/mine_menu_6.png new file mode 100644 index 0000000..f418183 Binary files /dev/null and b/assets/images/mine/mine_menu_6.png differ diff --git a/assets/images/mine/mine_menu_7.png b/assets/images/mine/mine_menu_7.png new file mode 100644 index 0000000..0d04d79 Binary files /dev/null and b/assets/images/mine/mine_menu_7.png differ diff --git a/assets/images/mine/mine_notification.png b/assets/images/mine/mine_notification.png new file mode 100644 index 0000000..a93dfa3 Binary files /dev/null and b/assets/images/mine/mine_notification.png differ diff --git a/assets/images/mine/mine_offical.png b/assets/images/mine/mine_offical.png new file mode 100644 index 0000000..008e014 Binary files /dev/null and b/assets/images/mine/mine_offical.png differ diff --git a/assets/images/mine/mine_post.png b/assets/images/mine/mine_post.png new file mode 100644 index 0000000..f779bc2 Binary files /dev/null and b/assets/images/mine/mine_post.png differ diff --git a/assets/images/mine/mine_shop_icon.webp b/assets/images/mine/mine_shop_icon.webp new file mode 100644 index 0000000..c2ca5ad Binary files /dev/null and b/assets/images/mine/mine_shop_icon.webp differ diff --git a/assets/images/mine/mine_vip_bg.webp b/assets/images/mine/mine_vip_bg.webp new file mode 100644 index 0000000..aaefae1 Binary files /dev/null and b/assets/images/mine/mine_vip_bg.webp differ diff --git a/assets/images/mine/mine_vip_enter.webp b/assets/images/mine/mine_vip_enter.webp new file mode 100644 index 0000000..691141e Binary files /dev/null and b/assets/images/mine/mine_vip_enter.webp differ diff --git a/assets/images/mine/mine_vip_entry_bg.webp b/assets/images/mine/mine_vip_entry_bg.webp new file mode 100644 index 0000000..14ca21b Binary files /dev/null and b/assets/images/mine/mine_vip_entry_bg.webp differ diff --git a/assets/images/mine/mine_withdraw_nor.png b/assets/images/mine/mine_withdraw_nor.png new file mode 100644 index 0000000..bde1f4f Binary files /dev/null and b/assets/images/mine/mine_withdraw_nor.png differ diff --git a/assets/images/mine/no_vip_bottom.webp b/assets/images/mine/no_vip_bottom.webp new file mode 100644 index 0000000..284bb81 Binary files /dev/null and b/assets/images/mine/no_vip_bottom.webp differ diff --git a/assets/images/mine/normal.png b/assets/images/mine/normal.png new file mode 100644 index 0000000..f8f518e Binary files /dev/null and b/assets/images/mine/normal.png differ diff --git a/assets/images/mine/pay_icon103.png b/assets/images/mine/pay_icon103.png new file mode 100644 index 0000000..0014db3 Binary files /dev/null and b/assets/images/mine/pay_icon103.png differ diff --git a/assets/images/mine/privilege_ai.webp b/assets/images/mine/privilege_ai.webp new file mode 100644 index 0000000..2f6aed2 Binary files /dev/null and b/assets/images/mine/privilege_ai.webp differ diff --git a/assets/images/mine/privilege_bg.webp b/assets/images/mine/privilege_bg.webp new file mode 100644 index 0000000..d5a3d39 Binary files /dev/null and b/assets/images/mine/privilege_bg.webp differ diff --git a/assets/images/mine/privilege_game.webp b/assets/images/mine/privilege_game.webp new file mode 100644 index 0000000..56b5bef Binary files /dev/null and b/assets/images/mine/privilege_game.webp differ diff --git a/assets/images/mine/privilege_gold.webp b/assets/images/mine/privilege_gold.webp new file mode 100644 index 0000000..85d5e52 Binary files /dev/null and b/assets/images/mine/privilege_gold.webp differ diff --git a/assets/images/mine/proxy_banner.webp b/assets/images/mine/proxy_banner.webp new file mode 100644 index 0000000..3fbf27d Binary files /dev/null and b/assets/images/mine/proxy_banner.webp differ diff --git a/assets/images/mine/proxy_header_bg.webp b/assets/images/mine/proxy_header_bg.webp new file mode 100644 index 0000000..4b8686c Binary files /dev/null and b/assets/images/mine/proxy_header_bg.webp differ diff --git a/assets/images/mine/red_checked.png b/assets/images/mine/red_checked.png new file mode 100644 index 0000000..be35b0a Binary files /dev/null and b/assets/images/mine/red_checked.png differ diff --git a/assets/images/mine/scan_photo.png b/assets/images/mine/scan_photo.png new file mode 100644 index 0000000..2a98dcc Binary files /dev/null and b/assets/images/mine/scan_photo.png differ diff --git a/assets/images/mine/select.png b/assets/images/mine/select.png new file mode 100644 index 0000000..126dc5d Binary files /dev/null and b/assets/images/mine/select.png differ diff --git a/assets/images/mine/setting.png b/assets/images/mine/setting.png new file mode 100644 index 0000000..6a020ef Binary files /dev/null and b/assets/images/mine/setting.png differ diff --git a/assets/images/mine/share_bg.webp b/assets/images/mine/share_bg.webp new file mode 100644 index 0000000..cb32a7b Binary files /dev/null and b/assets/images/mine/share_bg.webp differ diff --git a/assets/images/mine/sign_take_top.webp b/assets/images/mine/sign_take_top.webp new file mode 100644 index 0000000..5cbc0bf Binary files /dev/null and b/assets/images/mine/sign_take_top.webp differ diff --git a/assets/images/mine/user_center_bg.webp b/assets/images/mine/user_center_bg.webp new file mode 100644 index 0000000..2afc0b6 Binary files /dev/null and b/assets/images/mine/user_center_bg.webp differ diff --git a/assets/images/mine/vip_1.webp b/assets/images/mine/vip_1.webp new file mode 100644 index 0000000..ed7e47a Binary files /dev/null and b/assets/images/mine/vip_1.webp differ diff --git a/assets/images/mine/vip_2.webp b/assets/images/mine/vip_2.webp new file mode 100644 index 0000000..d48f838 Binary files /dev/null and b/assets/images/mine/vip_2.webp differ diff --git a/assets/images/mine/vip_3.webp b/assets/images/mine/vip_3.webp new file mode 100644 index 0000000..d059135 Binary files /dev/null and b/assets/images/mine/vip_3.webp differ diff --git a/assets/images/mine/vip_4.webp b/assets/images/mine/vip_4.webp new file mode 100644 index 0000000..ec40a24 Binary files /dev/null and b/assets/images/mine/vip_4.webp differ diff --git a/assets/images/mine/vip_arrow.svg b/assets/images/mine/vip_arrow.svg new file mode 100644 index 0000000..a8139d8 --- /dev/null +++ b/assets/images/mine/vip_arrow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/mine/vip_bottom.webp b/assets/images/mine/vip_bottom.webp new file mode 100644 index 0000000..4225903 Binary files /dev/null and b/assets/images/mine/vip_bottom.webp differ diff --git a/assets/images/mine/vip_more_title.webp b/assets/images/mine/vip_more_title.webp new file mode 100644 index 0000000..73d04ab Binary files /dev/null and b/assets/images/mine/vip_more_title.webp differ diff --git a/assets/images/mine/vip_privilege_bg.webp b/assets/images/mine/vip_privilege_bg.webp new file mode 100644 index 0000000..ab3cb75 Binary files /dev/null and b/assets/images/mine/vip_privilege_bg.webp differ diff --git a/assets/images/mine/vip_special_title.webp b/assets/images/mine/vip_special_title.webp new file mode 100644 index 0000000..bcd664c Binary files /dev/null and b/assets/images/mine/vip_special_title.webp differ diff --git a/assets/images/mine/vip_upgrade_arrow.svg b/assets/images/mine/vip_upgrade_arrow.svg new file mode 100644 index 0000000..90ce291 --- /dev/null +++ b/assets/images/mine/vip_upgrade_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/mine/welfare_normal_bg.webp b/assets/images/mine/welfare_normal_bg.webp new file mode 100644 index 0000000..b8564d7 Binary files /dev/null and b/assets/images/mine/welfare_normal_bg.webp differ diff --git a/assets/images/mine/welfare_vip_background.png b/assets/images/mine/welfare_vip_background.png new file mode 100644 index 0000000..92466b1 Binary files /dev/null and b/assets/images/mine/welfare_vip_background.png differ diff --git a/assets/images/mine/welfare_vip_background.webp b/assets/images/mine/welfare_vip_background.webp new file mode 100644 index 0000000..d2ff1ea Binary files /dev/null and b/assets/images/mine/welfare_vip_background.webp differ diff --git a/assets/images/mine/welfare_vip_bg.webp b/assets/images/mine/welfare_vip_bg.webp new file mode 100644 index 0000000..ba6c5c8 Binary files /dev/null and b/assets/images/mine/welfare_vip_bg.webp differ diff --git a/assets/images/video/add_red.webp b/assets/images/video/add_red.webp new file mode 100644 index 0000000..5fa6d29 Binary files /dev/null and b/assets/images/video/add_red.webp differ diff --git a/assets/images/video/cache_selected.webp b/assets/images/video/cache_selected.webp new file mode 100644 index 0000000..e0dcd1b Binary files /dev/null and b/assets/images/video/cache_selected.webp differ diff --git a/assets/images/video/cache_start.webp b/assets/images/video/cache_start.webp new file mode 100644 index 0000000..1d4d3be Binary files /dev/null and b/assets/images/video/cache_start.webp differ diff --git a/assets/images/video/cache_stop.webp b/assets/images/video/cache_stop.webp new file mode 100644 index 0000000..71e4dcf Binary files /dev/null and b/assets/images/video/cache_stop.webp differ diff --git a/assets/images/video/cache_unselected.webp b/assets/images/video/cache_unselected.webp new file mode 100644 index 0000000..d9a02f8 Binary files /dev/null and b/assets/images/video/cache_unselected.webp differ diff --git a/assets/images/video/chevron_right.webp b/assets/images/video/chevron_right.webp new file mode 100644 index 0000000..b95130c Binary files /dev/null and b/assets/images/video/chevron_right.webp differ diff --git a/assets/images/video/chevron_up.webp b/assets/images/video/chevron_up.webp new file mode 100644 index 0000000..6df1b26 Binary files /dev/null and b/assets/images/video/chevron_up.webp differ diff --git a/assets/images/video/circle_play.webp b/assets/images/video/circle_play.webp new file mode 100644 index 0000000..d809937 Binary files /dev/null and b/assets/images/video/circle_play.webp differ diff --git a/assets/images/video/comment.png b/assets/images/video/comment.png new file mode 100644 index 0000000..506b2e1 Binary files /dev/null and b/assets/images/video/comment.png differ diff --git a/assets/images/video/core_privilege_bg.webp b/assets/images/video/core_privilege_bg.webp new file mode 100644 index 0000000..7f4b70f Binary files /dev/null and b/assets/images/video/core_privilege_bg.webp differ diff --git a/assets/images/video/download.png b/assets/images/video/download.png new file mode 100644 index 0000000..003c614 Binary files /dev/null and b/assets/images/video/download.png differ diff --git a/assets/images/video/download_free.png b/assets/images/video/download_free.png new file mode 100644 index 0000000..1c79bb0 Binary files /dev/null and b/assets/images/video/download_free.png differ diff --git a/assets/images/video/download_short.png b/assets/images/video/download_short.png new file mode 100644 index 0000000..5935678 Binary files /dev/null and b/assets/images/video/download_short.png differ diff --git a/assets/images/video/eye_close.png b/assets/images/video/eye_close.png new file mode 100644 index 0000000..42b1f3f Binary files /dev/null and b/assets/images/video/eye_close.png differ diff --git a/assets/images/video/eye_open.png b/assets/images/video/eye_open.png new file mode 100644 index 0000000..d69cc41 Binary files /dev/null and b/assets/images/video/eye_open.png differ diff --git a/assets/images/video/full_drama.webp b/assets/images/video/full_drama.webp new file mode 100644 index 0000000..4f126cd Binary files /dev/null and b/assets/images/video/full_drama.webp differ diff --git a/assets/images/video/full_icon.png b/assets/images/video/full_icon.png new file mode 100644 index 0000000..69c2191 Binary files /dev/null and b/assets/images/video/full_icon.png differ diff --git a/assets/images/video/full_screen.webp b/assets/images/video/full_screen.webp new file mode 100644 index 0000000..70de994 Binary files /dev/null and b/assets/images/video/full_screen.webp differ diff --git a/assets/images/video/line_switch.webp b/assets/images/video/line_switch.webp new file mode 100644 index 0000000..ac23c1e Binary files /dev/null and b/assets/images/video/line_switch.webp differ diff --git a/assets/images/video/line_white2.png b/assets/images/video/line_white2.png new file mode 100644 index 0000000..4b63da7 Binary files /dev/null and b/assets/images/video/line_white2.png differ diff --git a/assets/images/video/pause_icon.webp b/assets/images/video/pause_icon.webp new file mode 100644 index 0000000..275e184 Binary files /dev/null and b/assets/images/video/pause_icon.webp differ diff --git a/assets/images/video/play.webp b/assets/images/video/play.webp new file mode 100644 index 0000000..8c54f4f Binary files /dev/null and b/assets/images/video/play.webp differ diff --git a/assets/images/video/share_arrow.png b/assets/images/video/share_arrow.png new file mode 100644 index 0000000..57fce5e Binary files /dev/null and b/assets/images/video/share_arrow.png differ diff --git a/assets/images/video/stopwatch.svg b/assets/images/video/stopwatch.svg new file mode 100644 index 0000000..9ea3885 --- /dev/null +++ b/assets/images/video/stopwatch.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/video/update_video.webp b/assets/images/video/update_video.webp new file mode 100644 index 0000000..057e01c Binary files /dev/null and b/assets/images/video/update_video.webp differ diff --git a/assets/images/video/video_like_border.png b/assets/images/video/video_like_border.png new file mode 100644 index 0000000..5f2d9c7 Binary files /dev/null and b/assets/images/video/video_like_border.png differ diff --git a/assets/images/video/video_like_grey.webp b/assets/images/video/video_like_grey.webp new file mode 100644 index 0000000..9549feb Binary files /dev/null and b/assets/images/video/video_like_grey.webp differ diff --git a/assets/images/video/voice_noval_icon.png b/assets/images/video/voice_noval_icon.png new file mode 100644 index 0000000..98faf03 Binary files /dev/null and b/assets/images/video/voice_noval_icon.png differ diff --git a/build_apk.sh b/build_apk.sh new file mode 100755 index 0000000..0b6a4d8 --- /dev/null +++ b/build_apk.sh @@ -0,0 +1,238 @@ +#!/bin/bash + +# 出错即停 +set -e + +# 自动使用 .fvmrc 中指定的 Flutter 版本(避免使用 PATH 里的系统 flutter 导致依赖冲突) +if [ -f "./.fvmrc" ]; then + FVM_VERSION=$(grep -o '"flutter"[[:space:]]*:[[:space:]]*"[^"]*"' .fvmrc | sed -E 's/.*"([^"]+)"$/\1/') + if [ -n "$FVM_VERSION" ] && [ -x "$HOME/fvm/versions/$FVM_VERSION/bin/flutter" ]; then + export PATH="$HOME/fvm/versions/$FVM_VERSION/bin:$PATH" + echo "🔧 使用 FVM Flutter 版本: $FVM_VERSION" + fi +fi + +# 参数说明 +if [ $# -lt 1 ]; then + printf -- "Build hgdj release package.\n\n" + printf -- "Usage: $0 [mode] [export_method] [export_options_plist] [sign_mode]\n" + printf -- " target : a(android) | i(ios) | all\n" + printf -- " mode : release(默认) | debug | profile\n" + printf -- " export_method (iOS) : app-store(默认) | ad-hoc | development | enterprise\n" + printf -- " export_options_plist : ExportOptions.plist 路径(iOS 可选,留空用默认)\n" + printf -- " sign_mode (iOS) : codesign(默认) | nocodesign\n\n" + printf -- "Examples:\n" + printf -- " $0 a # Android release\n" + printf -- " $0 a debug # Android debug\n" + printf -- " $0 i # iOS release + app-store\n" + printf -- " $0 i release ad-hoc # iOS release + ad-hoc\n" + printf -- " $0 i release app-store ios/ExportOptions.plist\n" + printf -- " $0 i release app-store \"\" nocodesign # iOS 不签名出包\n" + printf -- " $0 all # Android + iOS release\n" + exit 1 +fi + +TARGET=$1 +MODE=${2:-release} +EXPORT_METHOD=${3:-app-store} +EXPORT_OPTIONS_PLIST=${4:-} +SIGN_MODE=${5:-codesign} + +# 参数校验 +if [ "$TARGET" != "a" ] && [ "$TARGET" != "i" ] && [ "$TARGET" != "all" ]; then + echo "❌ 参数错误:target 只支持 a | i | all,你传的是 '$TARGET'" + exit 1 +fi + +if [ "$MODE" != "release" ] && [ "$MODE" != "debug" ] && [ "$MODE" != "profile" ]; then + echo "❌ 参数错误:mode 只支持 release | debug | profile,你传的是 '$MODE'" + exit 1 +fi + +case "$EXPORT_METHOD" in + app-store|ad-hoc|development|enterprise) ;; + *) + echo "❌ 参数错误:export_method 只支持 app-store | ad-hoc | development | enterprise,你传的是 '$EXPORT_METHOD'" + exit 1 + ;; +esac + +if [ "$SIGN_MODE" != "codesign" ] && [ "$SIGN_MODE" != "nocodesign" ]; then + echo "❌ 参数错误:sign_mode 只支持 codesign | nocodesign,你传的是 '$SIGN_MODE'" + exit 1 +fi + +if [ -n "$EXPORT_OPTIONS_PLIST" ] && [ ! -f "$EXPORT_OPTIONS_PLIST" ]; then + echo "❌ 未找到 ExportOptions.plist:$EXPORT_OPTIONS_PLIST" + exit 1 +fi + +# 项目根路径校验 +if [ ! -f "./pubspec.yaml" ]; then + echo "❌ 请在项目根目录下执行此脚本" + exit 1 +fi + +PROJECT_PATH=$(pwd) +PUBSPEC_FILE="pubspec.yaml" + +# 提取项目信息 +APP_NAME=$(grep '^name:' "$PUBSPEC_FILE" | awk '{print $2}') +RAW_VERSION=$(grep '^version:' "$PUBSPEC_FILE" | awk '{print $2}') +SAFE_VERSION=${RAW_VERSION//+/_} +TIME_TAG=$(date +"%m%d-%H%M") + +# 读取应用内 Config.isDebug(lib/config/config.dart),决定产物命名标识: +# isDebug=true → 命名标识固定为 debug(如 hgdj_debug_xxxx),方便区分内测包 +# isDebug=false → 正式环境,命名沿用构建模式(release/profile),保持不变 +CONFIG_FILE="lib/config/config.dart" +CONFIG_DEBUG=$(grep -E 'static[[:space:]]+const[[:space:]]+isDebug[[:space:]]*=' "$CONFIG_FILE" 2>/dev/null | grep -oE 'true|false' | head -1) +if [ "$CONFIG_DEBUG" = "true" ]; then + BUILD_TAG="debug" +elif [ "$CONFIG_DEBUG" = "false" ]; then + BUILD_TAG="$MODE" +else + echo "⚠️ 未能从 $CONFIG_FILE 解析 Config.isDebug,命名回退为构建模式:$MODE" + BUILD_TAG="$MODE" +fi + +DIST_DIR="./dist" +mkdir -p "$DIST_DIR" + +# 打印信息并二次确认 +echo "==========================================" +echo "📦 准备打包" +echo "📌 项目名: $APP_NAME" +echo "📌 版本号: $RAW_VERSION" +echo "📌 目标平台: $TARGET" +echo "📌 构建模式: $MODE" +echo "📌 Config.isDebug: ${CONFIG_DEBUG:-未知} → 命名标识: $BUILD_TAG" +if [ "$TARGET" = "i" ] || [ "$TARGET" = "all" ]; then + echo "📌 iOS 导出: $EXPORT_METHOD" + echo "📌 iOS 签名: $SIGN_MODE" + if [ -n "$EXPORT_OPTIONS_PLIST" ]; then + echo "📌 ExportOptions.plist: $EXPORT_OPTIONS_PLIST" + fi +fi +echo "📌 工程路径: $PROJECT_PATH" +echo "==========================================" +echo "确认无误?(按回车继续,Ctrl+C 取消)" +read -r CONFIRM + +# Android 构建 +build_android() { + echo "▶️ 开始构建 Android $MODE 包..." + + if [ "$MODE" = "release" ]; then + flutter build apk --release --target-platform android-arm64 + APK_SOURCE="build/app/outputs/flutter-apk/app-release.apk" + elif [ "$MODE" = "profile" ]; then + flutter build apk --profile --target-platform android-arm64 + APK_SOURCE="build/app/outputs/flutter-apk/app-profile.apk" + else + flutter build apk --debug --target-platform android-arm64 + APK_SOURCE="build/app/outputs/flutter-apk/app-debug.apk" + fi + + if [ ! -f "$APK_SOURCE" ]; then + echo "❌ 构建失败,未找到 APK:$APK_SOURCE" + exit 1 + fi + + APK_NAME="${APP_NAME}_${BUILD_TAG}_${SAFE_VERSION}_${TIME_TAG}.apk" + APK_DEST="${DIST_DIR}/${APK_NAME}" + cp "$APK_SOURCE" "$APK_DEST" + + APK_SIZE=$(du -h "$APK_DEST" | awk '{print $1}') + echo "✅ Android 构建成功!" + echo "📁 输出路径: $APK_DEST" + echo "📏 文件大小: $APK_SIZE" + LAST_BUILT_FILE="$APK_DEST" +} + +# iOS 构建(基于 flutter build ipa,一步出 ipa) +build_ios() { + echo "▶️ 开始构建 iOS $MODE 包..." + + if [ "$MODE" = "debug" ]; then + echo "⚠️ iOS 不支持 debug 模式打包,请使用 release 或 profile(debug 建议直接 flutter run)" + exit 1 + fi + + IPA_OUTPUT_DIR="build/ios/ipa" + ARCHIVE_OUTPUT_DIR="build/ios/archive" + + if [ "$MODE" = "release" ]; then + if [ "$SIGN_MODE" = "nocodesign" ]; then + flutter build ipa --release --no-codesign + elif [ -n "$EXPORT_OPTIONS_PLIST" ]; then + flutter build ipa --release --export-options-plist="$EXPORT_OPTIONS_PLIST" + else + flutter build ipa --release --export-method "$EXPORT_METHOD" + fi + else + if [ "$SIGN_MODE" = "nocodesign" ]; then + flutter build ipa --profile --no-codesign + elif [ -n "$EXPORT_OPTIONS_PLIST" ]; then + flutter build ipa --profile --export-options-plist="$EXPORT_OPTIONS_PLIST" + else + flutter build ipa --profile --export-method "$EXPORT_METHOD" + fi + fi + + # 查找产物 ipa + IPA_SOURCE="" + for candidate in "$IPA_OUTPUT_DIR"/*.ipa; do + if [ -f "$candidate" ]; then + IPA_SOURCE="$candidate" + break + fi + done + + if [ -z "$IPA_SOURCE" ]; then + if [ "$SIGN_MODE" = "nocodesign" ]; then + if [ -d "$ARCHIVE_OUTPUT_DIR" ]; then + echo "⚠️ 未签名模式下 Flutter 通常不会导出可安装的 IPA" + echo "✅ 未签名构建产物目录:$ARCHIVE_OUTPUT_DIR" + echo "📌 如需可安装/可上传的 IPA,请在 Xcode 配好 Team 和证书后用 codesign 模式重新打包" + LAST_BUILT_FILE="" + return + fi + echo "❌ 未签名构建完成,但未找到 archive 目录:$ARCHIVE_OUTPUT_DIR" + exit 1 + fi + echo "❌ 构建结束,但未找到 IPA 文件:$IPA_OUTPUT_DIR/*.ipa" + echo "📌 通常是签名证书、Provisioning Profile 或 ExportOptions 配置有问题" + echo "📌 可以先试不签名出包:$0 i $MODE $EXPORT_METHOD \"$EXPORT_OPTIONS_PLIST\" nocodesign" + exit 1 + fi + + # 与 apk 命名对齐:去掉冗余的 ios 标识(后缀已区分平台),导出方式去连字符(ad-hoc→adhoc) + IPA_NAME="${APP_NAME}_${BUILD_TAG}_${EXPORT_METHOD//-/}_${SAFE_VERSION}_${TIME_TAG}.ipa" + IPA_DEST="${DIST_DIR}/${IPA_NAME}" + cp "$IPA_SOURCE" "$IPA_DEST" + + IPA_SIZE=$(du -h "$IPA_DEST" | awk '{print $1}') + echo "✅ iOS 构建成功!" + echo "📁 输出路径: $IPA_DEST" + echo "📏 文件大小: $IPA_SIZE" + LAST_BUILT_FILE="$IPA_DEST" +} + +# 分发 +case "$TARGET" in + a) build_android ;; + i) build_ios ;; + all) build_android; build_ios ;; +esac + +# 自动在 Finder 中打开并选中产物(仅 macOS) +if [[ "$OSTYPE" == "darwin"* ]]; then + if [ -n "$LAST_BUILT_FILE" ] && [ -f "$LAST_BUILT_FILE" ]; then + open -R "$LAST_BUILT_FILE" + else + open "$DIST_DIR" + fi +fi + +echo "🎉 完成" diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..7e7e7f6 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1 @@ +extensions: diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist new file mode 100644 index 0000000..1ffe25a --- /dev/null +++ b/ios/ExportOptions.plist @@ -0,0 +1,25 @@ + + + + + method + ad-hoc + signingStyle + manual + teamID + NWP977LR25 + signingCertificate + iPhone Distribution + provisioningProfiles + + com.kakaxi + hoc_kakaxi + + stripSwiftSymbols + + compileBitcode + + uploadSymbols + + + diff --git a/ios/ExportOptions.plist.example b/ios/ExportOptions.plist.example new file mode 100644 index 0000000..55a7e86 --- /dev/null +++ b/ios/ExportOptions.plist.example @@ -0,0 +1,16 @@ + + + + + method + app-store + signingStyle + automatic + stripSwiftSymbols + + compileBitcode + + teamID + YOUR_TEAM_ID + + diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Flutter.podspec b/ios/Flutter/Flutter.podspec new file mode 100644 index 0000000..3aed58d --- /dev/null +++ b/ios/Flutter/Flutter.podspec @@ -0,0 +1,18 @@ +# +# This podspec is NOT to be published. It is only used as a local source! +# This is a generated file; do not edit or check into version control. +# + +Pod::Spec.new do |s| + s.name = 'Flutter' + s.version = '1.0.0' + s.summary = 'A UI toolkit for beautiful and fast apps.' + s.homepage = 'https://flutter.dev' + s.license = { :type => 'BSD' } + s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } + s.source = { :git => 'https://github.com/flutter/engine', :tag => s.version.to_s } + s.ios.deployment_target = '13.0' + # Framework linking is handled by Flutter tooling, not CocoaPods. + # Add a placeholder to satisfy `s.dependency 'Flutter'` plugin podspecs. + s.vendored_frameworks = 'path/to/nothing' +end diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/ephemeral/flutter_lldb_helper.py b/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 0000000..a88caf9 --- /dev/null +++ b/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/ios/Flutter/ephemeral/flutter_lldbinit b/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 0000000..e3ba6fb --- /dev/null +++ b/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..fad0050 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,89 @@ +platform :ios, '15.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +# 修复 recognition_qrcode 在 Xcode 26 下:public header 直接 #import +# 会触发 "Include of non-modular header inside framework module" 错误。 +# 这里把它改成前向声明,并把真正的 import 挪到 .m 文件。 +def patch_recognition_qrcode! + pub_cache = ENV['PUB_CACHE'] || File.expand_path('~/.pub-cache') + Dir.glob(File.join(pub_cache, 'hosted', '*', 'recognition_qrcode-*', 'ios', 'Classes')).each do |dir| + h = File.join(dir, 'ImageViewController.h') + m = File.join(dir, 'ImageViewController.m') + + if File.exist?(h) + content = File.read(h) + if content.include?('#import ') + new_content = content.sub('#import ', '@class MLKBarcode;') + File.write(h, new_content) + puts "[patch] recognition_qrcode ImageViewController.h -> forward declare MLKBarcode" + end + end + + if File.exist?(m) + content = File.read(m) + unless content.include?('#import ') + new_content = content.sub( + '#import "ImageViewController.h"', + "#import \"ImageViewController.h\"\n#import " + ) + File.write(m, new_content) + puts "[patch] recognition_qrcode ImageViewController.m -> import MLKit" + end + end + end +end +patch_recognition_qrcode! + +target 'Runner' do + use_frameworks! :linkage => :static + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_PHOTOS=1', + 'PERMISSION_PHOTOS_ADD_ONLY=1', + 'PERMISSION_CAMERA=1', + ] + # 允许 framework 模块内引用非模块化头(解决 recognition_qrcode 引入 GoogleMLKit 的报错) + config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES' + # 强制所有 Pods 子 target 部署版本对齐到 15.0,避免 Xcode 26 报警/报错 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + end + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..e6e0ae5 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,263 @@ +PODS: + - analytics_sdk (0.0.1): + - Flutter + - audio_session (0.0.1): + - Flutter + - audioplayers_darwin (0.0.1): + - Flutter + - battery_plus (1.0.0): + - Flutter + - connectivity_plus (0.0.1): + - Flutter + - ReachabilitySwift + - device_identity (0.0.1): + - Flutter + - device_info_plus (0.0.1): + - Flutter + - ffmpeg_kit_flutter_new_https_gpl (8.1.2): + - ffmpeg_kit_flutter_new_https_gpl/https_gpl (= 8.1.2) + - Flutter + - ffmpeg_kit_flutter_new_https_gpl/https_gpl (8.1.2): + - Flutter + - Flutter (1.0.0) + - flutter_image_compress_common (1.0.0): + - Flutter + - Mantle + - SDWebImage + - SDWebImageWebPCoder + - flutter_inappwebview_ios (0.0.1): + - Flutter + - flutter_inappwebview_ios/Core (= 0.0.1) + - OrderedSet (~> 6.0.3) + - flutter_inappwebview_ios/Core (0.0.1): + - Flutter + - OrderedSet (~> 6.0.3) + - flutter_mm_core (1.0.0): + - Flutter + - flutter_secure_storage (6.0.0): + - Flutter + - image_gallery_saver_plus (0.0.1): + - Flutter + - image_picker_ios (0.0.1): + - Flutter + - install_plugin (2.0.0): + - Flutter + - libwebp (1.5.0): + - libwebp/demux (= 1.5.0) + - libwebp/mux (= 1.5.0) + - libwebp/sharpyuv (= 1.5.0) + - libwebp/webp (= 1.5.0) + - libwebp/demux (1.5.0): + - libwebp/webp + - libwebp/mux (1.5.0): + - libwebp/demux + - libwebp/sharpyuv (1.5.0) + - libwebp/webp (1.5.0): + - libwebp/sharpyuv + - m3u8_downloader (1.3.0): + - Flutter + - Mantle (2.2.0): + - Mantle/extobjc (= 2.2.0) + - Mantle/extobjc (2.2.0) + - MMKV (1.3.16): + - MMKVCore (~> 1.3.16) + - mmkv_ios (1.0.9): + - Flutter + - MMKV (< 2.0, >= 1.3.16) + - MMKVCore (1.3.16) + - MTBBarcodeScanner (5.0.11) + - OrderedSet (6.0.3) + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.4.8): + - Flutter + - qr_code_scanner (0.2.0): + - Flutter + - MTBBarcodeScanner + - ReachabilitySwift (5.2.1) + - screen_brightness_ios (0.1.0): + - Flutter + - screen_state (1.0.0): + - Flutter + - SDWebImage (5.21.7): + - SDWebImage/Core (= 5.21.7) + - SDWebImage/Core (5.21.7) + - SDWebImageWebPCoder (0.15.0): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.17) + - sensors_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS + - video_thumbnail (0.0.1): + - Flutter + - libwebp + - volume_controller (0.0.1): + - Flutter + +DEPENDENCIES: + - analytics_sdk (from `.symlinks/plugins/analytics_sdk/ios`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`) + - battery_plus (from `.symlinks/plugins/battery_plus/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - device_identity (from `.symlinks/plugins/device_identity/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - ffmpeg_kit_flutter_new_https_gpl (from `.symlinks/plugins/ffmpeg_kit_flutter_new_https_gpl/ios`) + - Flutter (from `Flutter`) + - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) + - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) + - flutter_mm_core (from `.symlinks/plugins/flutter_mm_core/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - image_gallery_saver_plus (from `.symlinks/plugins/image_gallery_saver_plus/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - install_plugin (from `.symlinks/plugins/install_plugin/ios`) + - m3u8_downloader (from `.symlinks/plugins/m3u8_downloader/ios`) + - mmkv_ios (from `.symlinks/plugins/mmkv_ios/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - qr_code_scanner (from `.symlinks/plugins/qr_code_scanner/ios`) + - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`) + - screen_state (from `.symlinks/plugins/screen_state/ios`) + - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`) + - volume_controller (from `.symlinks/plugins/volume_controller/ios`) + +SPEC REPOS: + trunk: + - libwebp + - Mantle + - MMKV + - MMKVCore + - MTBBarcodeScanner + - OrderedSet + - ReachabilitySwift + - SDWebImage + - SDWebImageWebPCoder + +EXTERNAL SOURCES: + analytics_sdk: + :path: ".symlinks/plugins/analytics_sdk/ios" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/ios" + battery_plus: + :path: ".symlinks/plugins/battery_plus/ios" + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" + device_identity: + :path: ".symlinks/plugins/device_identity/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + ffmpeg_kit_flutter_new_https_gpl: + :path: ".symlinks/plugins/ffmpeg_kit_flutter_new_https_gpl/ios" + Flutter: + :path: Flutter + flutter_image_compress_common: + :path: ".symlinks/plugins/flutter_image_compress_common/ios" + flutter_inappwebview_ios: + :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" + flutter_mm_core: + :path: ".symlinks/plugins/flutter_mm_core/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + image_gallery_saver_plus: + :path: ".symlinks/plugins/image_gallery_saver_plus/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + install_plugin: + :path: ".symlinks/plugins/install_plugin/ios" + m3u8_downloader: + :path: ".symlinks/plugins/m3u8_downloader/ios" + mmkv_ios: + :path: ".symlinks/plugins/mmkv_ios/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + qr_code_scanner: + :path: ".symlinks/plugins/qr_code_scanner/ios" + screen_brightness_ios: + :path: ".symlinks/plugins/screen_brightness_ios/ios" + screen_state: + :path: ".symlinks/plugins/screen_state/ios" + sensors_plus: + :path: ".symlinks/plugins/sensors_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" + video_thumbnail: + :path: ".symlinks/plugins/video_thumbnail/ios" + volume_controller: + :path: ".symlinks/plugins/volume_controller/ios" + +SPEC CHECKSUMS: + analytics_sdk: e7e64a4d0d7762c8d2dd17ac703fa0c85e1d655d + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 + audioplayers_darwin: ccf9c770ee768abb07e26d90af093f7bab1c12ab + battery_plus: ae71a65eceb5680dfc7a1d926f6cde59a1b219d0 + connectivity_plus: 4d5101c5877bedcda6cd1ccb825307911667d6b0 + device_identity: e268e0b896b956f1bd61e9a14a453a40ecc63ee0 + device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89 + ffmpeg_kit_flutter_new_https_gpl: 56be062be665d420377894a6edc86a6ed9be3e00 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 + flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 + flutter_mm_core: 24789ae032943fe59859f18270a9b825d268260b + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + image_gallery_saver_plus: e597bf65a7846979417a3eae0763b71b6dfec6c3 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + install_plugin: e17e38d6f504857748a3ec1299d8a2bbeeeea854 + libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + m3u8_downloader: 7f4f2645fca62d25bdd270bf010e0401fa65dac1 + Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d + MMKV: 43cd10be0f925bf3fd5c2bd8bea501a328037b64 + mmkv_ios: 94e046dd1bbb7c2bf43844e64ba719db05dcb711 + MMKVCore: 13be45a0394861b74836875ac9ec7ee939e86b78 + MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb + OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e + qr_code_scanner: d77f94ecc9abf96d9b9b8fc04ef13f611e5a147a + ReachabilitySwift: 5ae15e16814b5f9ef568963fb2c87aeb49158c66 + screen_brightness_ios: 5ed898fa50fa82a26171c086ca5e28228f932576 + screen_state: 52d6e997d31bddba6417c60d9cdd22effd0320a7 + SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf + SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 + sensors_plus: 1c5f0a01ce21c609a4df404c4e6879d62bce287f + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 + video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 + volume_controller: ca1cde542ee70fad77d388f82e9616488110942b + +PODFILE CHECKSUM: 7d7da56638d7839868e1c8c784b28551f4fd1ab8 + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7a859e6 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,767 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + 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 */; }; + 7DBCE06031927E891E8104CF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 376BE01EA9B1B19648EB1C4E /* Pods_RunnerTests.framework */; }; + 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 */; }; + E8589FF20EB10064C4254D7B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8AB5FB2CCD63E5EE3FB87C5F /* Pods_Runner.framework */; }; +/* 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 = ""; }; + 17A48C2C7F52D7B3246BF7D1 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; 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; }; + 376BE01EA9B1B19648EB1C4E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3A1D8A92F3F46D5434B0DE7A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 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 = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8AB5FB2CCD63E5EE3FB87C5F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; + BA0D97EE605633099C68EDFE /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + C80C3D00356FB958F78A425A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + CDBF003887C9B885DBC72F8C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + E9BE6309466FE5B6A729BEC7 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 004E6F0FC0B8C1E1EF24A5B1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7DBCE06031927E891E8104CF /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E8589FF20EB10064C4254D7B /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 68F9F73AACA31E1FD7ABC67D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 8AB5FB2CCD63E5EE3FB87C5F /* Pods_Runner.framework */, + 376BE01EA9B1B19648EB1C4E /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 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 */, + BC02200744B4A6D9228CB266 /* Pods */, + 68F9F73AACA31E1FD7ABC67D /* Frameworks */, + ); + 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 */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + BC02200744B4A6D9228CB266 /* Pods */ = { + isa = PBXGroup; + children = ( + 17A48C2C7F52D7B3246BF7D1 /* Pods-Runner.debug.xcconfig */, + CDBF003887C9B885DBC72F8C /* Pods-Runner.release.xcconfig */, + C80C3D00356FB958F78A425A /* Pods-Runner.profile.xcconfig */, + E9BE6309466FE5B6A729BEC7 /* Pods-RunnerTests.debug.xcconfig */, + 3A1D8A92F3F46D5434B0DE7A /* Pods-RunnerTests.release.xcconfig */, + BA0D97EE605633099C68EDFE /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + BD3D0679CBD7EC2CAC48B0CA /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 004E6F0FC0B8C1E1EF24A5B1 /* Frameworks */, + ); + 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 = ( + 345223D30F10A24DE550C3A4 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 88C07363DC1853F271C44A41 /* [CP] Copy Pods Resources */, + 4F8B6F3BC6A26C849E203BE1 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + 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; + 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 */ + 345223D30F10A24DE550C3A4 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 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"; + }; + 4F8B6F3BC6A26C849E203BE1 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 88C07363DC1853F271C44A41 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 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"; + }; + BD3D0679CBD7EC2CAC48B0CA /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* 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 */, + ); + 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; + 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; + 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 = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = NWP977LR25; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = 黄果短剧; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kakaxi; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = hoc_kakaxi; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E9BE6309466FE5B6A729BEC7 /* Pods-RunnerTests.debug.xcconfig */; + 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.akaka.hgdj.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; + baseConfigurationReference = 3A1D8A92F3F46D5434B0DE7A /* Pods-RunnerTests.release.xcconfig */; + 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.akaka.hgdj.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; + baseConfigurationReference = BA0D97EE605633099C68EDFE /* Pods-RunnerTests.profile.xcconfig */; + 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.akaka.hgdj.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; + 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; + 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 = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + 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; + 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 = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = NWP977LR25; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = 黄果短剧; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kakaxi; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = hoc_kakaxi; + 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; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = NWP977LR25; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = 黄果短剧; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kakaxi; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = hoc_kakaxi; + STRIP_INSTALLED_PRODUCT = NO; + STRIP_STYLE = "non-global"; + 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 */ + }; + 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..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + 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..c15d3fc --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,32 @@ +import UIKit +import Flutter +import VideoToolbox + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + + // H.265 硬解能力查询通道(复用 com.yinse/device)。VTIsHardwareDecodeSupported 天然是硬解判断, + // iOS11+ 且 A9(iPhone 6s)起为 true,旧机型返回 false → 回落 264。 + if let controller = window?.rootViewController as? FlutterViewController { + let channel = FlutterMethodChannel(name: "com.yinse/device", binaryMessenger: controller.binaryMessenger) + channel.setMethodCallHandler { call, result in + if call.method == "isH265HardwareSupported" { + if #available(iOS 11.0, *) { + result(VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC)) + } else { + result(false) + } + } else { + result(FlutterMethodNotImplemented) + } + } + } + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} 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..193d0dc --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,158 @@ +{ + "images": [ + { + "size": "20x20", + "idiom": "iphone", + "filename": "icon-20@2x.png", + "scale": "2x" + }, + { + "size": "20x20", + "idiom": "iphone", + "filename": "icon-20@3x.png", + "scale": "3x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "icon-29.png", + "scale": "1x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "icon-29@2x.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "icon-29@3x.png", + "scale": "3x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "icon-40@2x.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "iphone", + "filename": "icon-40@3x.png", + "scale": "3x" + }, + { + "size": "57x57", + "idiom": "iphone", + "filename": "icon-57.png", + "scale": "1x" + }, + { + "size": "57x57", + "idiom": "iphone", + "filename": "icon-57@2x.png", + "scale": "2x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "icon-60@2x.png", + "scale": "2x" + }, + { + "size": "60x60", + "idiom": "iphone", + "filename": "icon-60@3x.png", + "scale": "3x" + }, + { + "size": "20x20", + "idiom": "ipad", + "filename": "icon-20-ipad.png", + "scale": "1x" + }, + { + "size": "20x20", + "idiom": "ipad", + "filename": "icon-20@2x-ipad.png", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "ipad", + "filename": "icon-29-ipad.png", + "scale": "1x" + }, + { + "size": "29x29", + "idiom": "ipad", + "filename": "icon-29@2x-ipad.png", + "scale": "2x" + }, + { + "size": "40x40", + "idiom": "ipad", + "filename": "icon-40.png", + "scale": "1x" + }, + { + "size": "40x40", + "idiom": "ipad", + "filename": "icon-40@2x.png", + "scale": "2x" + }, + { + "size": "50x50", + "idiom": "ipad", + "filename": "icon-50.png", + "scale": "1x" + }, + { + "size": "50x50", + "idiom": "ipad", + "filename": "icon-50@2x.png", + "scale": "2x" + }, + { + "size": "72x72", + "idiom": "ipad", + "filename": "icon-72.png", + "scale": "1x" + }, + { + "size": "72x72", + "idiom": "ipad", + "filename": "icon-72@2x.png", + "scale": "2x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "icon-76.png", + "scale": "1x" + }, + { + "size": "76x76", + "idiom": "ipad", + "filename": "icon-76@2x.png", + "scale": "2x" + }, + { + "size": "83.5x83.5", + "idiom": "ipad", + "filename": "icon-83.5@2x.png", + "scale": "2x" + }, + { + "size": "1024x1024", + "idiom": "ios-marketing", + "filename": "icon-1024.png", + "scale": "1x" + } + ], + "info": { + "version": 1, + "author": "icon.wuruihong.com" + } +} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..28c897e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png new file mode 100644 index 0000000..394682b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20-ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png new file mode 100644 index 0000000..4362c60 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20@2x-ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png new file mode 100644 index 0000000..4362c60 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png new file mode 100644 index 0000000..9010875 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png new file mode 100644 index 0000000..a17c413 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29-ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29.png new file mode 100644 index 0000000..a17c413 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png new file mode 100644 index 0000000..2a5b135 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29@2x-ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png new file mode 100644 index 0000000..2a5b135 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png new file mode 100644 index 0000000..eeb3996 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-40.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-40.png new file mode 100644 index 0000000..4362c60 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-40.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png new file mode 100644 index 0000000..086a550 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png new file mode 100644 index 0000000..043464d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-50.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-50.png new file mode 100644 index 0000000..be2cbe1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-50.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-50@2x.png new file mode 100644 index 0000000..eb940c6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-57.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-57.png new file mode 100644 index 0000000..f07caf3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-57.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-57@2x.png new file mode 100644 index 0000000..7dbfa0c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png new file mode 100644 index 0000000..043464d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png new file mode 100644 index 0000000..a738ca6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-72.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-72.png new file mode 100644 index 0000000..0f2204f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-72.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-72@2x.png new file mode 100644 index 0000000..913a18c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-76.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-76.png new file mode 100644 index 0000000..9a80e82 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-76.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png new file mode 100644 index 0000000..438978b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png new file mode 100644 index 0000000..1fcfde9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/Contents.json b/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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..ac7c9a1 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ic_splash_bg 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ic_splash_bg.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_splash_bg 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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/Assets.xcassets/LaunchImage.imageset/ic_splash_bg 1.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/ic_splash_bg 1.png new file mode 100644 index 0000000..985f308 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/ic_splash_bg 1.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/ic_splash_bg 2.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/ic_splash_bg 2.png new file mode 100644 index 0000000..985f308 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/ic_splash_bg 2.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/ic_splash_bg.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/ic_splash_bg.png new file mode 100644 index 0000000..985f308 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/ic_splash_bg.png differ diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..af81004 --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..fe8c670 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,66 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 黄果短剧 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + 黄果短剧 + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSBonjourServices + + _dartVmService._tcp + _dartobservatory._tcp + + NSLocalNetworkUsageDescription + 调试模式下需要通过本地网络连接开发工具 + NSCameraUsageDescription + 拍照、扫码登录需要访问您的相机。 + NSPhotoLibraryAddUsageDescription + 保存图片到相册需要访问您的照片。 + NSPhotoLibraryUsageDescription + 保存图片到相册需要访问您的照片。 + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarStyle + UIStatusBarStyleLightContent + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + 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/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/lib/alert/aw_permission_alert.dart b/lib/alert/aw_permission_alert.dart new file mode 100644 index 0000000..5869deb --- /dev/null +++ b/lib/alert/aw_permission_alert.dart @@ -0,0 +1,106 @@ +import 'dart:ui'; + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/routers/jump_router.dart'; + +import '../hj_utils/screen.dart'; +import '../tools_base/widget/net_image_widget.dart'; + +/// 暗网板块未开通会员时的全屏毛玻璃遮罩:配了运营图走整图样式,否则走默认文案样式 +class AwPermissionAlert extends StatelessWidget { + const AwPermissionAlert({super.key}); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.black.withValues(alpha: .4), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Config.darkWebImg?.isNotEmpty == true + ? _imageView() + : _normalView(), + ), + ); + } + + //运营配置的整图样式,点哪都去开通 + Widget _imageView() { + return GestureDetector( + onTap: () => pushToWalletPage(vipId: Config.darkWebVipId), + //用 DecoratedBox 不用 ColoredBox:后者 hitTest 是 opaque,会让图片没加载出来时整屏都可点,和原行为不一致 + child: DecoratedBox( + decoration: BoxDecoration(color: Colors.black.withValues(alpha: .7)), + child: NetworkImageLoader( + imageUrl: Config.darkWebImg, + fit: BoxFit.cover, + borderRadius: 0, + ), + ), + ); + } + + //默认文案样式 + Widget _normalView() { + final vipName = Config.darkWebVipName ?? ''; + return Center( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => pushToWalletPage(tabPosition: 0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset('aw_warning.webp'.homePath, height: 96), + 28.sizeBoxH, + const Text( + '系统警告\n' + '【暗网】含有百万全球禁播封杀资源,全球\n' + '上流社会权贵的私密丑闻!\n\n' + '萝莉岛事件,N号房,暴力重口,巴以/俄\n' + '乌/中东战争,血腥缅北,呦呦破处,变态\n' + '恐怖,暗网交易,等稀缺资源!', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + 18.sizeBoxH, + //会员卡名标红 + EasyRichText( + '马上开通\n$vipName\n进入世界的黑暗面!', + textAlign: TextAlign.center, + defaultStyle: const TextStyle( + color: Colors.white, fontSize: 14, height: 1.5), + patternList: [ + if (vipName.isNotEmpty) + EasyRichTextPattern( + targetString: vipName, + matchWordBoundaries: false, // 纯中文无 \b 词边界,关掉才能匹配上 + hasSpecialCharacters: + true, // 卡名是服务端下发的,带 ()+ 等正则符会构造出非法正则崩掉 + style: const TextStyle(color: Color(0xffC40F0B)), + ), + ], + ), + 18.sizeBoxH, + GestureDetector( + onTap: () => pushToWalletPage(vipId: Config.darkWebVipId), + child: Image.asset('aw_button.webp'.homePath, height: 62), + ), + 30.sizeBoxH, + Text( + '此板块为黑客破解内容,真实稀缺资源,\n' + '可能会引起极度不适,请谨慎进入!\n', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: .6), fontSize: 12), + ), + ], + ), + ), + ); + } +} diff --git a/lib/alert/mine/vip_level_dialog.dart b/lib/alert/mine/vip_level_dialog.dart new file mode 100644 index 0000000..67cdc5c --- /dev/null +++ b/lib/alert/mine/vip_level_dialog.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/common_dialog.dart'; + +import '../../routers/jump_router.dart'; + +/// 显示vipdialog isJump 是否在dialog 内部处理跳转事件 +Future showVipLevelDialog( + String content, { + String buttonTitle = '立即开通', + String? desc, + Function? vipEvent, +}) { + return Get.dialog( + barrierColor: Colors.black.withValues(alpha: .4), + barrierDismissible: true, + Container( + alignment: Alignment.center, + child: CommonDialog( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "溫馨提示", + style: TextStyle( + fontSize: 20, + color: Color(0xE5FFFFFF), + fontWeight: FontWeight.w500, + ), + ), + 24.sizeBoxH, + Container( + padding: EdgeInsets.symmetric(horizontal: 4), + alignment: Alignment.center, + child: Text( + content, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, color: Color(0x73FFFFFF), height: 1.5), + ), + ), + if (desc != null && desc.isNotEmpty) + Container( + padding: EdgeInsets.only(top: 16), + child: Text( + desc, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: Color(0x73FFFFFF)), + ), + ), + 24.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () { + if (vipEvent != null) { + vipEvent.call(); + } else { + pushToWalletPage(tabPosition: 0); + } + }, + child: Container( + alignment: Alignment.center, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: Color(0xffF68804), + ), + child: Text( + buttonTitle, + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/alert/splash/app_center_dialog.dart b/lib/alert/splash/app_center_dialog.dart new file mode 100644 index 0000000..c35b6da --- /dev/null +++ b/lib/alert/splash/app_center_dialog.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; + +import '../../hj_model/splash/ads_model.dart'; +import '../../hj_utils/screen.dart'; +import '../../tools_base/banner/ads_banner_widget.dart'; +import '../../tools_base/banner/ads_item.dart'; + +class AppCenterDialog extends StatefulWidget { + final List? appList; + final List? adsList; + final VoidCallback? callback; + + const AppCenterDialog({super.key, this.appList, this.callback, this.adsList}); + + @override + State createState() => _AppCenterDialogState(); +} + +class _AppCenterDialogState extends State { + bool _showClose = false; + + List get _appList => widget.appList ?? []; + + /// 网格高度上限 = 4 行 + 底部留白,超出滚动。数字跟下面 gridDelegate 对应,改一处要同步。 + /// 别用「首帧 shrinkWrap 量高度、次帧再改成固定高」那套——两帧布局不同,弹窗又居中,打开瞬间会跳 + double get _maxGridHeight { + final itemWidth = (Get.width - 32 * 2 - 12 * 3) / 4; + return itemWidth * 93 / 68 * 4 + 16 * 3 + 25; + } + + @override + void initState() { + super.initState(); + // 1 秒后再显示关闭按钮,防误触 + Future.delayed(const Duration(milliseconds: 1000), () { + if (mounted) setState(() => _showClose = true); + }); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.black.withValues(alpha: 0.6), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 32), + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.adsList?.isNotEmpty == true) + Container( + margin: const EdgeInsets.only(bottom: 24), + child: AdsBannerWidget( + widget.adsList, + width: 329, + height: 88, + autoPlayMs: 5000, + ), + ), + //Flexible 兜住矮屏:内容再多也不会顶破屏幕 + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: _maxGridHeight), + child: GridView.builder( + padding: const EdgeInsets.only(bottom: 25), + shrinkWrap: true, //不足 4 行时按内容收,不留空 + physics: const ClampingScrollPhysics(), + itemCount: _appList.length, + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + childAspectRatio: 68 / 93, + ), + itemBuilder: (context, index) { + return AdsItem( + adInfo: _appList[index], + ); + }, + ), + ), + ), + 32.sizeBoxH, + //一直挂在树上只切显隐:等 _showClose 才 build 的话,asset 解码前图高是 0, + //会先塌一下再弹回来,居中布局跟着抖 + Opacity( + opacity: _showClose ? 1 : 0, + child: IgnorePointer( + ignoring: !_showClose, + child: GestureDetector( + onTap: () => Get.back(), + child: Image.asset("close_button.png".commonImgPath, + width: 32, height: 32), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/alert/splash/line_error_dialog.dart b/lib/alert/splash/line_error_dialog.dart new file mode 100644 index 0000000..0dd0f44 --- /dev/null +++ b/lib/alert/splash/line_error_dialog.dart @@ -0,0 +1,145 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/common_dialog.dart'; + +class LineErrorDialog extends StatelessWidget { + static Future show({Function? callback}) { + return Get.dialog( + LineErrorDialog(callback: callback), + barrierDismissible: false, + ); + } + + final Function? callback; + + const LineErrorDialog({super.key, this.callback}); + + String get url => '91porn01.cc'; + + @override + Widget build(BuildContext context) { + return CommonDialog( + //线路异常必须让用户处理,Get.dialog 的 barrierDismissible 到不了这层 + barrierDismissible: false, + canTapClose: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '系统提示', + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + 12.sizeBoxH, + 0.5.line, + 12.sizeBoxH, + Row( + children: [ + Text( + "APP线路检测失败", + style: TextStyle( + color: Color(0xffF52C56), + fontSize: 12, + ), + ), + ], + ), + 12.sizeBoxH, + Container( + alignment: Alignment.centerLeft, + child: const Text( + "解决方案:", + style: TextStyle( + color: Color(0xff656565), + fontSize: 12, + fontWeight: FontWeight.w500), + ), + ), + 12.sizeBoxH, + EasyRichText( + '''1.请检测您的网络是否正常,手机是否开了VPN或10分钟后再尝试访问。 +2.前往官网地址 $url下载最新版。 +3.前往Telegram${Config.appName}官方交流群 + ''', + defaultStyle: + TextStyle(fontSize: 12, color: Color(0xff656565), height: 1.8), + patternList: [ + EasyRichTextPattern( + targetString: url, + style: TextStyle(color: AppColors.primaryHighColor), + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrlToWeb('https://$url'); + }, + ), + ], + ), + 18.sizeBoxH, + SizedBox( + height: 38, + child: Row( + children: [ + GestureDetector( + onTap: () async { + Get.back(); + callback?.call(); + }, + child: Container( + width: 96, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: Colors.white.withValues(alpha: .1), + ), + child: Text( + "切换路线", + style: TextStyle( + color: Color(0xff989898), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + 12.sizeBoxW, + Expanded( + child: GestureDetector( + onTap: () { + launchUrlToWeb('https://t.me/hougong9527'); + }, + child: Container( + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed, + ), + child: const Text( + "前往群聊", + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ) + ], + ), + ) + ], + ), + ); + } +} diff --git a/lib/alert/splash/notice_image_dialog.dart b/lib/alert/splash/notice_image_dialog.dart new file mode 100644 index 0000000..d960571 --- /dev/null +++ b/lib/alert/splash/notice_image_dialog.dart @@ -0,0 +1,91 @@ +// ignore_for_file: prefer_const_constructors, use_key_in_widget_constructors, use_full_hex_values_for_flutter_colors, sized_box_for_whitespace, unnecessary_import + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/splash/ads_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../tools_base/banner/ads_item.dart'; + +/// 系统公告--图片公告 +class NoticeImageDialog extends StatelessWidget { + final AdsInfoModel? model; + + NoticeImageDialog({this.model}); + + @override + Widget build(BuildContext context) { + return Center( + child: Material( + color: Colors.transparent, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AdsItem( + adInfo: model!, + showType: AdShowType.img, + aspectRatio: 6 / 8, + ), + 20.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () => Get.back(result: false), + child: Image.asset("close_button.png".commonImgPath, width: 32), + ) + ], + ), + ), + ), + ); + } +} + +/// 系统公告--上下双排图片公告 +class SystemImagesDialog extends StatelessWidget { + final List? ads; + + SystemImagesDialog({this.ads}); + + @override + Widget build(BuildContext context) { + return Center( + child: Material( + color: Colors.transparent, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListView.separated( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return AspectRatio( + aspectRatio: 300 / 169, + child: AdsItem( + adInfo: ads![index], + showType: AdShowType.img, + aspectRatio: 6 / 8, + ), + ); + }, + separatorBuilder: (context, index) => 6.sizeBoxH, + itemCount: ads?.length ?? 0, + ), + 20.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () => Get.back(result: false), + child: Image.asset("close_button.png".commonImgPath, width: 32), + ) + ], + ), + ), + ), + ); + } +} diff --git a/lib/alert/splash/notice_text_dialog.dart b/lib/alert/splash/notice_text_dialog.dart new file mode 100644 index 0000000..d8f2978 --- /dev/null +++ b/lib/alert/splash/notice_text_dialog.dart @@ -0,0 +1,85 @@ +// ignore_for_file: prefer_const_constructors, use_key_in_widget_constructors + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/common_dialog.dart'; + +import '../../hj_model/splash/ads_model.dart'; + +/// 系统公告--文字公告 +class NoticeTextDialog extends StatelessWidget { + const NoticeTextDialog({ + this.content, + this.addr, + this.wordBean, + }); + + final AnnounceInfoBean? wordBean; + final String? content; + final String? addr; + + @override + Widget build(BuildContext context) { + return CommonDialog( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + wordBean?.title ?? "官方公告", + style: TextStyle( + fontSize: 20, + color: Colors.white.withValues(alpha: 0.9), + fontWeight: FontWeight.w500, + ), + ), + 12.sizeBoxH, + 0.5.line, + 12.sizeBoxH, + SizedBox( + height: 220, + child: SingleChildScrollView( + child: EasyRichText( + content ?? "", + defaultStyle: TextStyle( + color: Color(0xff656565), fontSize: 12, height: 25 / 12), + patternList: [ + EasyRichTextPattern( + targetString: + '(http(s)?:\\/\\/.)?(www\\.)?[-a-zA-Z0-9:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)|https:\/\/t\\.me\/[0-9A-Za-z]{2,256}', + urlType: 'web', + style: TextStyle( + decoration: TextDecoration.underline, + color: Colors.blue, + ), + ), + ], + ), + ), + ), + 12.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () => Get.back(), + child: Container( + alignment: Alignment.center, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed), + child: Text( + "我知道了", + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/alert/splash/update_dialog.dart b/lib/alert/splash/update_dialog.dart new file mode 100644 index 0000000..eb1ed58 --- /dev/null +++ b/lib/alert/splash/update_dialog.dart @@ -0,0 +1,260 @@ +// ignore_for_file: depend_on_referenced_packages + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/install_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/common_dialog.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +import '../../hj_model/splash/domain_source_model.dart'; +import '../../hj_page/mine/identity/mine_identity_page.dart'; +import '../../routers/jump_router.dart'; +import '../../tools_base/net/load_apk/dio_cli.dart'; +import '../../tools_base/net/load_apk/dio_slice_downloader.dart'; +import '../../track_event_manager/device_service.dart'; + +//升级版本对话框 +class UpdateDialog extends StatefulWidget { + static Future show(CheckVersionInfo phoneBean) async { + return await Get.dialog( + UpdateDialog(updateInfo: phoneBean), + barrierDismissible: false, + ); + } + + final CheckVersionInfo updateInfo; + + const UpdateDialog({super.key, required this.updateInfo}); + + @override + State createState() => _UpdateDialog(); +} + +class _UpdateDialog extends State { + bool isDownloading = false; // 下载中:既防重入,也决定进度条露不露 + var progress = .0; + + String apkPath = ""; + + /// 点「立即升级」:iOS 跳商店,Android 下载 apk,下完再点就是安装 + void _onUpdate() async { + //iOS 没有下载安装 apk 那一套,直接跳 App Store;进度条也就不用起(isDownloading 不置位) + if (Platform.isIOS) { + final iosUrl = widget.updateInfo.iosUrl ?? ''; + iosUrl.isEmpty ? showToast("暂无更新地址,请稍后再试") : await launchUrlToWeb(iosUrl); + return; + } + if (progress == 1.0) { + await InstallUtil.installApk(apkPath); + return; + } + if (isDownloading) { + return; + } + //开始下载 + isDownloading = true; + String devId = DeviceInfoService.deviceId; + Clipboard.setData(ClipboardData(text: "***$devId^^^")); + _downloadApk(widget.updateInfo.url ?? ""); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return CommonDialog( + //强更场景要挡住点空白关闭,Get.dialog 的 barrierDismissible 到不了这层 + barrierDismissible: false, + canTapClose: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + '系统升级', + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + 12.sizeBoxH, + 0.5.line, + 12.sizeBoxH, + Row( + children: [ + Text( + "更新版本 v ${widget.updateInfo.verName}", + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 12, + ), + ), + ], + ), + 12.sizeBoxH, + SizedBox( + height: 175, + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Row( + children: [ + //Row 不给约束,Text 会按自然宽度排版撑出去,必须 Expanded 收回可用宽度才会换行 + Expanded( + child: Text( + widget.updateInfo.description + ?.replaceAll("|", "\n") ?? + "", + style: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 12, + height: 1.6, + ), + ), + ), + ], + ), + ), + ), + ], + ), + 18.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () => Get.to(() => const MineAccountIdentityPage()), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "保存账号凭证", + style: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 14, + ), + ), + 3.sizeBoxW, + Icon( + Icons.arrow_forward_ios_outlined, + size: 14, + color: Color(0xffDCDCDC), + ) + ], + ), + ), + 18.sizeBoxH, + if (isDownloading) ...[ + Container( + padding: EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: progress, + minHeight: 3, + backgroundColor: const Color(0xffBEBEBE), + valueColor: AlwaysStoppedAnimation(AppColors.actionRed), + ), + ), + 12.sizeBoxH, + Text( + "${((progress * 100).toInt())}%", + style: TextStyle( + fontSize: 16, + color: Colors.white.withValues(alpha: .55), + ), + ), + ], + ), + ), + ] else + _buildButtonMenu(), + ], + ), + ); + } + + Widget _buildButtonMenu() { + if (widget.updateInfo.forcedUpdate == true) { + return _buildUpdateBtn(); + } else { + return Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => Get.back(), + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: Colors.white.withValues(alpha: .1), + ), + child: Text( + "暂不升级", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xff989898), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded(child: _buildUpdateBtn()), + ], + ); + } + } + + Widget _buildUpdateBtn() { + return GestureDetector( + onTap: _onUpdate, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: const Text( + "立即升级", + style: TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + ///下载 apk(仅 Android,iOS 走商店) + Future _downloadApk(String url) async { + final saveDirectory = + path.join((await getExternalStorageDirectory())?.path ?? "", "apk"); + DioSliceRetryDownloader(DioCli(), url, null, saveDirectory, "paofu.apk", + onReceiveProgress: (count, total) { + progress = (count / total); + setState(() {}); + }, onRetry: (int retryCount) { + showToast("下载发生错误正在自动重试中...($retryCount)"); + }).download().then((String savePath) async { + apkPath = savePath; + await InstallUtil.installApk(apkPath); + }).catchError((e) { + showToast("下载发生错误,点击重新进行下载."); + setState(() => isDownloading = false); + }); + } +} diff --git a/lib/alert/video/buy_vip_alert.dart b/lib/alert/video/buy_vip_alert.dart new file mode 100644 index 0000000..a2726aa --- /dev/null +++ b/lib/alert/video/buy_vip_alert.dart @@ -0,0 +1,510 @@ +// ignore_for_file: use_build_context_synchronously + +import 'dart:math' as math; + +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; +import 'package:provider/provider.dart'; + +import '../../hj_model/splash/domain_source_model.dart'; +import '../../hj_page/main_page/provider/msg_provider.dart'; +import '../../hj_page/mine/mine_vip/mine_charge_vip_logic.dart'; +import '../../hj_page/mine/mine_vip/pay_order_source.dart'; +import '../../hj_page/mine/mine_vip/vip_card_item.dart'; +import '../../hj_page/mine/mine_vip/vip_pay_button.dart'; +import '../../hj_page/mine/mine_vip/vip_product_manager.dart'; +import '../../hj_page/mine/mine_vip/vip_support_model.dart'; +import '../../hj_page/mine/mine_vip/vip_ui_kit.dart'; +import '../../routers/jump_router.dart'; +import '../../tools_base/loading/loading_center_widget.dart'; +import 'buy_vip_alert_logic.dart'; + +///购买视频对话框 +class BuyVipAlert extends StatefulWidget { + final String? vipId; //外部指定卡ID(可选),不传走分层卡/升级卡兜底 + final String? videoId; //在看的那条内容 id,下单时透传给服务端做归因 + final PayOrderTrackInfo? + orderTrack; //整份下单上下文,短剧付费墙要带 mediaId/contentId/checkoutContextId + const BuyVipAlert({super.key, this.vipId, this.videoId, this.orderTrack}); + + static Future show( + {String? vipId, String? videoId, PayOrderTrackInfo? orderTrack}) { + return Get.bottomSheet( + BuyVipAlert(vipId: vipId, videoId: videoId, orderTrack: orderTrack), + isScrollControlled: true, + ); + } + + @override + State createState() => _BuyVipAlertState(); +} + +// 可叠弹(试看结束/超时长等多处触发),用 per-实例唯一 tag 隔离 controller +class _BuyVipAlertState extends State with UniqueTagMixin { + late final logic = BuyVipAlertLogic( + vId: widget.vipId, + videoId: widget.videoId, + orderTrack: widget.orderTrack); + + static const _sheetRadius = 26.0; + + /// 顶部可见边:上→下 #66EBD097 → #15EBD097 → #00EBD097(顶边最实,圆角向下渐隐) + static const _borderGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x66EBD097), + Color(0x15EBD097), + Color(0x00EBD097), + ], + ); + + /// 只订阅「选中卡变化」的局部刷新:标题/价格/卡片选中态/权益/支付按钮用它, + /// 滑卡时不再整个弹窗重建(与会员中心页共用 [MineChargeVipLogic.kSelection]) + Widget _selectionBuilder(Widget Function(BuyVipAlertLogic logic) builder) { + return GetBuilder( + tag: uniqueTag, + id: MineChargeVipLogic.kSelection, + builder: builder, + ); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: logic, + builder: (_) => Material( + color: Colors.transparent, + child: SizedBox( + height: screen.screenHeight * 0.8, + width: screen.screenWidth, + child: Stack( + clipBehavior: Clip.none, + children: [ + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(_sheetRadius), + topRight: Radius.circular(_sheetRadius), + ), + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xff2B2010), + Color(0xff1A140B), + Color(0xff100C07), + ], + ), + ), + child: Column( + children: [ + 16.sizeBoxH, + const SheetHandleBar(color: Color(0x22FFFFFF)), + 16.sizeBoxH, + // 标题 + 分层倒计时(对齐设计稿:新人特惠/卡名 + 红块 HH:MM:SS) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _selectionBuilder( + (_) => Text( + _title, + style: const TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 16, + fontWeight: FontWeight.w500), + ), + ), + _buildCountdown(), + ], + ), + 14.sizeBoxH, + _selectionBuilder( + (_) => _buildPriceInfo()), //原价 / 限时特价 / 优惠 + 16.sizeBoxH, + Expanded( + child: _buildContent(), + ) + ], + ), + ), + ), + // 只描顶部 + 左右上圆角;竖向渐变让顶边最亮、沿圆角向下淡出 + Positioned( + top: 0, + left: 0, + right: 0, + height: _sheetRadius + 2, + child: IgnorePointer( + child: CustomPaint( + painter: _BuyVipTopBorderPainter( + gradient: _borderGradient, + strokeWidth: 1, + topRadius: _sheetRadius, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildContent() { + return Consumer( + builder: (_, mgr, __) { + // 与会员中心一致:仅 ACTIVE+B 用改版;A / DISABLED 用 classic + final classic = !mgr.isNewVipUi; + if (mgr.isLoading) return LoadingCenterWidget(); + if (mgr.vipCards.isEmpty) return CErrorWidget(); + return Stack( + children: [ + Column( + children: [ + // 会员卡实体 + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 6), + child: CarouselSlider.builder( + carouselController: logic.pageCtr, + itemCount: mgr.vipCards.length + 2, + itemBuilder: (_, index, __) => _selectionBuilder( + (l) => l.instanceChildItem(index)), + options: CarouselOptions( + height: logic.cardHeight, + viewportFraction: logic.itemRatio, + initialPage: 0, + enableInfiniteScroll: false, + reverse: false, + autoPlay: false, + enlargeCenterPage: false, + padEnds: false, + clipBehavior: Clip.none, + onPageChanged: (index, __) => + logic.onPageChanged(index), + scrollDirection: Axis.horizontal, + ), + ), + ), + // B:卡皮与核心权益标题间距略收;A 保持原 14 + (classic ? 14 : 4).sizeBoxH, + _buildPrivilegeSections(classic: classic), + 100.sizeBoxH, + ], + ), + ), + ), + ], + ), + Positioned( + bottom: screen.paddingBottom + 12, + left: 12, + right: 12, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _selectionBuilder((l) => VipPayButton(l, classic: classic)), + 12.sizeBoxH, + EasyRichText( + '支付问题反馈,点击联系 在线客服', + patternList: [ + EasyRichTextPattern( + targetString: '在线客服', + style: const TextStyle( + color: Color(0xFFFFD460), fontSize: 12), + recognizer: TapGestureRecognizer() + ..onTap = () => pushToCustomService(), + ) + ], + defaultStyle: + const TextStyle(color: Color(0xffDCDCDC), fontSize: 12), + ), + ], + ), + ) + ], + ); + }, + ); + } + + /// 弹窗标题:新人分层固定"新人特惠",其他分层跟随当前选中卡名 + String get _title { + if (MineMsgProvider().payTier?.status == PayTier.newUnpay) return '新人特惠'; + return logic.currentProductModel?.productName ?? '会员特惠'; + } + + /// 价格行:原价(标题+价格删除线) / 限时特价 / 优惠 + Widget _buildPriceInfo() { + final p = logic.currentProductModel; + if (p == null) return const SizedBox(); + final original = p.originalPriceUI; + final discounted = p.discountedPriceUI; + const muted = TextStyle( + color: Color(0x8cFFFFFF), + fontSize: 14, + decoration: TextDecoration.lineThrough, + decorationColor: Color(0x8cFFFFFF), + ); + const normal = TextStyle( + color: Color(0x8cFFFFFF), fontSize: 14, fontWeight: FontWeight.w500); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _priceItem("原价", "¥$original", muted, titleStyle: muted), + 24.sizeBoxW, + _priceItem("限时特价", "¥$discounted", normal), + 24.sizeBoxW, + _priceItem( + "优惠", "¥${(original - discounted).clamp(0, original)}", normal), + ], + ); + } + + Widget _priceItem(String title, String value, TextStyle valueStyle, + {TextStyle? titleStyle}) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, + style: titleStyle ?? + const TextStyle(color: Color(0x99FFFFFF), fontSize: 14)), + 4.sizeBoxW, + Text(value, style: valueStyle), + ], + ); + } + + /// 分层倒计时:按 config.lastDiscountTime,有则显示(监听 tick 每秒刷新),过期自动收起 + Widget _buildCountdown() { + return ValueListenableBuilder( + valueListenable: MineMsgProvider().tick, + builder: (_, __, ___) { + final c = MineMsgProvider().payTier?.config; + if (c == null || !c.hasDiscountCountdown) + return const SizedBox.shrink(); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + 12.sizeBoxW, + _timeItem(c.discountHour), + _colon(), + _timeItem(c.discountMin), + _colon(), + _timeItem(c.discountSec), + ], + ); + }, + ); + } + + Widget _timeItem(String value) { + return Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xffE1351F), + borderRadius: BorderRadius.circular(3)), + child: Text(value, + style: const TextStyle( + color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500)), + ); + } + + Widget _colon() => const Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: + Text(":", style: TextStyle(color: Color(0xffDD001B), fontSize: 20)), + ); + + /// 权益区:与会员中心同实验分支 —— classic=A/DISABLED,改版=ACTIVE+B + Widget _buildPrivilegeSections({required bool classic}) { + return _selectionBuilder((l) { + final privileges = l.currentProductModel?.newPrivilege ?? []; + final coreList = privileges.where((p) => p.isCore == true).toList(); + final moreList = privileges.where((p) => p.isCore != true).toList(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + classic + ? _buildClassicCorePrivileges(coreList) + : _buildCorePrivileges(coreList), + classic + ? _buildClassicMorePrivileges(moreList) + : _buildMorePrivileges(moreList), + ], + ); + }); + } + + // 核心权益:横向滚动卡片 —— B 改版 + Widget _buildCorePrivileges(List list) { + if (list.isEmpty) return const SizedBox.shrink(); + return Column( + children: [ + const VipCoreSectionTitleImage(), + 16.sizeBoxH, + SizedBox( + height: 92, + child: ListView.separated( + clipBehavior: Clip.none, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 2, 16, 26), + itemCount: list.length, + separatorBuilder: (_, __) => 8.sizeBoxW, + itemBuilder: (_, i) => VipCorePrivilegeCard(list[i]), + ), + ), + 16.sizeBoxH, + ], + ); + } + + // 核心权益:一行金边方卡横向滑动 —— A / DISABLED + Widget _buildClassicCorePrivileges(List list) { + if (list.isEmpty) return const SizedBox.shrink(); + return Column( + children: [ + const VipSectionTitle('我的核心权益', classic: true), + 16.sizeBoxH, + SizedBox( + //设计稿一行放得下 4 张多一点,第 5 张露半张提示可以划 + height: 72, + child: ListView.separated( + clipBehavior: Clip.none, // 金边有外发光,裁了会缺一圈 + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: list.length, + separatorBuilder: (_, __) => 8.sizeBoxW, + itemBuilder: (_, i) => SizedBox( + width: 72, child: VipCorePrivilegeCard(list[i], classic: true)), + ), + ), + 16.sizeBoxH, + ], + ); + } + + // 更多权益:深色圆角 + 四列网格 —— B 改版 + Widget _buildMorePrivileges(List list) { + if (list.isEmpty) return const SizedBox.shrink(); + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: const Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const VipMoreSectionTitleImage(), + 12.sizeBoxH, + GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 6), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 0, + crossAxisSpacing: 14, + childAspectRatio: 66 / 110, + ), + itemCount: list.length, + itemBuilder: (_, i) => VipProductPrivilegeItem(list[i]), + ), + ], + ), + ); + } + + // 会员特权:直接四列网格 —— A / DISABLED + Widget _buildClassicMorePrivileges(List list) { + if (list.isEmpty) return const SizedBox.shrink(); + return Column( + children: [ + const VipSectionTitle('我的会员特权', classic: true), + 12.sizeBoxH, + GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 18), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 12, + crossAxisSpacing: 18, + childAspectRatio: 63 / 100, + ), + itemCount: list.length, + itemBuilder: (_, i) => + VipProductPrivilegeItem(list[i], classic: true), + ), + ], + ); + } +} + +/// 仅绘制底部弹窗顶部可见边界(上边 + 左右上圆角) +class _BuyVipTopBorderPainter extends CustomPainter { + final Gradient gradient; + final double strokeWidth; + final double topRadius; + + _BuyVipTopBorderPainter({ + required this.gradient, + required this.strokeWidth, + required this.topRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final r = topRadius; + final half = strokeWidth / 2; + // Flutter arcTo:0=右、正角度顺时针。左上圆心(r,r) 由 π 扫到 3π/2,右上圆心(w-r,r) 由 3π/2 扫到 2π + final path = Path() + ..moveTo(half, r) + ..arcTo( + Rect.fromCircle(center: Offset(r, r), radius: r - half), + math.pi, + math.pi / 2, + false, + ) + ..lineTo(w - r, half) + ..arcTo( + Rect.fromCircle(center: Offset(w - r, r), radius: r - half), + math.pi * 1.5, + math.pi / 2, + false, + ); + + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..isAntiAlias = true + ..strokeJoin = StrokeJoin.round + ..shader = gradient.createShader(Rect.fromLTWH(0, 0, w, r + 2)); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _BuyVipTopBorderPainter oldDelegate) { + return oldDelegate.gradient != gradient || + oldDelegate.strokeWidth != strokeWidth || + oldDelegate.topRadius != topRadius; + } +} diff --git a/lib/alert/video/buy_vip_alert_logic.dart b/lib/alert/video/buy_vip_alert_logic.dart new file mode 100644 index 0000000..5fc58f7 --- /dev/null +++ b/lib/alert/video/buy_vip_alert_logic.dart @@ -0,0 +1,15 @@ +import '../../hj_page/mine/mine_vip/mine_charge_vip_logic.dart'; +import '../../hj_page/mine/mine_vip/pay_order_source.dart'; + +class BuyVipAlertLogic extends MineChargeVipLogic { + //外部指定卡ID(可选);不传则走分层卡/升级卡兜底,对齐 mrhs + //视频场景购买弹窗 → VIDEO_BOTTOM_SHEET + BuyVipAlertLogic({String? vId, String? videoId, PayOrderTrackInfo? orderTrack}) + : super(vipID: vId, sourcePage: PaySourcePage.videoBottomSheet, videoId: videoId, orderTrack: orderTrack); + + @override + bool get preferFreshData => false; //弹窗优先用 manager 已加载的缓存,避免重复请求 + + @override + bool get enableEntryGuidePopup => false; //购买弹窗本身就是付费引导,不再叠「进会员页」倒计时弹窗 +} diff --git a/lib/alert/video/comic_buy_alert.dart b/lib/alert/video/comic_buy_alert.dart new file mode 100644 index 0000000..019beb8 --- /dev/null +++ b/lib/alert/video/comic_buy_alert.dart @@ -0,0 +1,189 @@ +// ignore_for_file: use_build_context_synchronously + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; +import 'package:provider/provider.dart'; + +import '../../assets_tool/app_colors.dart'; +import '../../hj_model/acg/comic_chapters_model.dart'; +import '../../hj_model/cartoon_media_info.dart'; +import '../../hj_utils/pay/pay_manager.dart'; +import '../../routers/jump_router.dart'; +import '../../tools_base/global_store/store.dart'; + +///购买漫画/小说弹窗 +class ComicBuyAlert extends StatefulWidget { + //// 返回值: 1 购买单集, 2 购买全集,null 啥都没购买 + static Future show({ + ComicChapterInfo? curModel, // 单集数据 + CartoonMediaInfo? mediaInfo, // 全集数据 + }) async { + return await Get.bottomSheet(ComicBuyAlert( + mediaInfo: mediaInfo, + curModel: curModel, + )); + } + + final ComicChapterInfo? curModel; // 单集数据 + final CartoonMediaInfo? mediaInfo; // 全集数据 + + const ComicBuyAlert({ + super.key, + this.curModel, + this.mediaInfo, + }); + + @override + State createState() => _ComicBuyAlertState(); +} + +class _ComicBuyAlertState extends State { + bool _isBuying = false; + + //整套价格(产品当前只开整套购买) + int get _price => widget.mediaInfo?.price ?? 0; + + @override + void initState() { + super.initState(); + //刷完钱包会 notifyListeners,下面的 Consumer 自动重建,不用再 setState + globalStore.refreshWallet(); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Consumer( + builder: (_, store, __) { + final enough = (store.wallet?.amount ?? 0) >= _price; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + decoration: const BoxDecoration( + color: AppColors.primaryColor, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SheetHandleBar(), + 18.sizeBoxH, + const Text( + '购买作品', + style: TextStyle( + color: Color(0xE5ffffff), + fontSize: 20, + fontWeight: FontWeight.w500), + ), + 36.sizeBoxH, + //作品解锁价 + Row( + children: [ + const Text('作品解锁', + style: + TextStyle(color: Color(0xE5ffffff), fontSize: 14)), + const Spacer(), + GestureDetector( + onTap: () => pushToWalletPage(tabPosition: 1), + child: Text( + '$_price 金币', + style: const TextStyle( + color: Color(0xffFF9000), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ), + ], + ), + 18.sizeBoxH, + Divider( + height: 0.5, color: Colors.white.withValues(alpha: .05)), + 18.sizeBoxH, + //我的金币余额 + 充值入口 + Row( + children: [ + const Text('我的金币', + style: + TextStyle(color: Color(0xE5ffffff), fontSize: 14)), + const Spacer(), + Text( + '${store.wallet?.amount?.toDouble().fixed(2) ?? 0}', + style: const TextStyle( + color: Color(0xE5ffffff), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + 12.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: () => pushToWalletPage(tabPosition: 1), + child: const Text( + '充值', + style: TextStyle( + color: Color(0xffA4634D), + fontSize: 14, + fontWeight: FontWeight.w400, + decoration: TextDecoration.underline, + decorationColor: Color(0xffA4634D), + ), + ), + ), + ], + ), + 18.sizeBoxH, + //支付按钮:余额不足直接去充值 + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => enough + ? _buyProduct(true) + : pushToWalletPage(tabPosition: 1), + child: Container( + width: double.infinity, + height: 47, + decoration: BoxDecoration( + color: const Color(0xffF68804), + borderRadius: BorderRadius.circular(3)), + alignment: Alignment.center, + child: Text( + enough ? '$_price金币/立即支付' : '余额不足,前往充值', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + ), + ), + ], + ), + ); + }, + ), + ); + } + + ///购买作品:[isAll] true 整本(19),false 单集(25) + Future _buyProduct(bool isAll) async { + if (_isBuying) return; //纯防重入,build 不依赖它,改了不用 setState + _isBuying = true; + final productType = isAll ? ProductType.mediaAll : ProductType.mediaChapter; + final productID = isAll + ? (widget.mediaInfo?.id ?? widget.curModel?.mediaId) + : widget.curModel?.id; + await PayManager().buy( + productID, + productType, + source: 'comic_buy', + onSuccess: (data) { + globalStore.refreshWallet(); + showToast("购买成功"); + Get.back(result: isAll ? 2 : 1); + }, + ); + _isBuying = false; + } +} diff --git a/lib/alert/video/share_media_dialog.dart b/lib/alert/video/share_media_dialog.dart new file mode 100644 index 0000000..01f44f9 --- /dev/null +++ b/lib/alert/video/share_media_dialog.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_model/user/user_info_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/common_dialog.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../../config/address.dart'; +import '../../hj_model/video_model.dart'; +import '../../hj_utils/image_util.dart'; +import '../../tools_base/global_store/store.dart'; + +///分享二维码对话框 +class ShareMediaDialog extends StatefulWidget { + final VideoModel? videoModel; + final bool needDecrypt; //封面是否需要解密(直播封面不加密,传 false) + + const ShareMediaDialog({ + super.key, + this.videoModel, + this.needDecrypt = true, + }); + + @override + State createState() => _ShareMediaDialogState(); +} + +class _ShareMediaDialogState extends State { + final _shotKey = GlobalKey(); //保存图片时截取的范围 + + UserInfoModel? get _me => globalStore.meInfo; + + //推广链接:弹窗打开时取一次即可(二维码/复制都用它) + late final String _shareUrl = _me?.promoteURL ?? ""; + + /// 复制链接 + void _onCopyLink() { + if (_shareUrl.isEmpty) { + showToast("链接生成错误"); + return; + } + Clipboard.setData(ClipboardData(text: _shareUrl)); + showToast("复制成功,快去分享吧"); + Get.back(); + } + + /// 保存图片(必须等截图完成再关弹窗,否则 _shotKey 对应的 widget 已销毁) + Future _onSaveImg() async { + final ok = await ImageUtil.saveWidgetToAlbum(_shotKey); + showToast(ok ? "保存成功,快去分享吧" : "保存失败,请重试"); + if (mounted) Get.back(); // 保存期间弹窗可能已被关掉(连点/点了复制),别多 pop 一层 + } + + @override + Widget build(BuildContext context) { + return CommonDialog( + padding: EdgeInsets.zero, + child: RepaintBoundary( + key: _shotKey, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 23, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + //app 图标 + 名称 + 标语 + Row( + children: [ + Image.asset('ic_launcher.webp'.commonImgPath, + width: 48.w, height: 48.w), + 10.w.sizeBoxW, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Config.appName, + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontWeight: FontWeight.w500, + fontSize: 14), + ), + 4.sizeBoxH, + Text("亚洲最火成人网站", + style: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 10)), + ], + ), + ], + ), + 18.sizeBoxH, + //视频封面 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 27), + child: NetworkImageLoader( + width: double.infinity, + imageUrl: widget.videoModel?.cover ?? "", + fit: BoxFit.fitHeight, + encrypt: widget.needDecrypt, + ), + ), + 18.sizeBoxH, + //二维码 + 邀请码 + 官网地址 + SizedBox( + height: 94, + child: Row( + children: [ + Container( + width: 94, + height: 94, + padding: const EdgeInsets.all(2), + color: Colors.white, + child: QrImageView( + data: _shareUrl, padding: EdgeInsets.zero), + ), + 16.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "邀请码${_me?.promotionCode ?? ""}", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: .9)), + ), + 20.sizeBoxH, + Text( + '官方网址:${Address.groundUrl ?? ""}', + maxLines: 2, + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: .55)), + ), + ], + ), + ), + ], + ), + ), + 24.sizeBoxH, + //底部两个按钮 + Row( + children: [ + Expanded( + child: _button( + "复制链接", + bgColor: Colors.white.withValues(alpha: .1), + textColor: const Color(0xff989898), + onTap: _onCopyLink, + ), + ), + 16.sizeBoxW, + Expanded( + child: _button( + "保存图片", + bgColor: AppColors.actionRed, + textColor: Colors.white, + onTap: _onSaveImg, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _button(String text, + {required Color bgColor, + required Color textColor, + required VoidCallback onTap}) { + return InkWell( + enableFeedback: false, + onTap: onTap, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: bgColor, + ), + child: Text( + text, + style: TextStyle( + fontSize: 16, color: textColor, fontWeight: FontWeight.w500), + ), + ), + ); + } +} diff --git a/lib/alert/video/video_buy_bottom_sheet.dart b/lib/alert/video/video_buy_bottom_sheet.dart new file mode 100644 index 0000000..abed8d2 --- /dev/null +++ b/lib/alert/video/video_buy_bottom_sheet.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/mine/mine_vip/widgets/coin_pay_bottom_sheet.dart'; +import 'package:hgdj/hj_page/mine/widgets/gradient_text.dart'; +import 'package:hgdj/hj_utils/pay/pay_manager.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; +import 'package:provider/provider.dart'; + +///金币购买作品面板 +class VideoBuyBottomSheet extends StatefulWidget { + final VideoModel video; + + const VideoBuyBottomSheet({super.key, required this.video}); + + @override + State createState() => _VideoBuyBottomSheetState(); +} + +class _VideoBuyBottomSheetState extends State { + VideoModel get _video => widget.video; + + bool get _isCartoon => _video.videoType == 1; + + bool get _isSeedLink => _video.newsType == 'SEED_LINK'; + + //图集和黄游金币解锁,没有vip折扣 + bool get _noDiscount => _video.newsType == "PIC" || _isSeedLink; + + //实际售价:无折扣品类按原价,其余按折后价 + String get _priceText => + '${(_noDiscount ? _video.originCoins : _video.coins) ?? 0} 金币'; + + @override + void initState() { + super.initState(); + globalStore.updateUserInfo(); + globalStore.refreshWallet(); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (_, store, __) { + final enough = (store.wallet?.amount ?? 0) >= (_video.realCoins ?? 0); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + decoration: const BoxDecoration( + color: AppColors.primaryColor, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SheetHandleBar(), + 18.sizeBoxH, + const Text( + '购买作品', + style: TextStyle( + color: Color(0xE5ffffff), + fontSize: 20, + fontWeight: FontWeight.w500), + ), + 18.sizeBoxH, + //作品解锁价 + Row( + children: [ + const Text('作品解锁', + style: TextStyle(color: Color(0xE5ffffff), fontSize: 14)), + const Spacer(), + GestureDetector( + onTap: () => CoinPayBottomSheet.show(), + child: Text( + _priceText, + style: const TextStyle( + color: Color(0xffFF9000), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ), + ], + ), + 18.sizeBoxH, + //图集和黄游金币解锁,没有vip折扣,不展示原价和折扣说明 + if (!_noDiscount) ..._discountRows(store), + Divider(height: 0.5, color: Colors.white.withValues(alpha: .05)), + 18.sizeBoxH, + //我的金币余额 + 充值入口 + Row( + children: [ + const Text('我的金币', + style: TextStyle(color: Color(0xE5ffffff), fontSize: 14)), + const Spacer(), + Text( + '${store.wallet?.amount?.toDouble().fixed(2) ?? 0}', + style: const TextStyle( + color: Color(0xE5ffffff), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + 12.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: () => CoinPayBottomSheet.show(), + child: const Text( + '充值', + style: TextStyle( + color: Color(0xffA4634D), + fontSize: 14, + fontWeight: FontWeight.w400, + decoration: TextDecoration.underline, + decorationColor: Color(0xffA4634D), + ), + ), + ), + ], + ), + 18.sizeBoxH, + //底部两个按钮:立即支付 / 开通会员 + Row( + children: [ + Expanded( + child: _button( + enough ? '$_priceText/立即支付' : '余额不足,前往充值', + textColor: const Color(0xff7B0000), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xfff8f3ae), Color(0xffddac44)], + ), + borderRadius: BorderRadius.circular(3), + ), + onTap: () => _onPay(enough), + ), + ), + 12.sizeBoxW, + Expanded( + child: _button( + '开通会员免费看', + textColor: Colors.white, + decoration: BoxDecoration( + color: const Color(0xfff68804), + borderRadius: BorderRadius.circular(3), + ), + onTap: () => pushToWalletPage(), + ), + ), + ], + ), + ], + ), + ); + }, + ); + } + + //原价 + 折扣说明(有 vip 折扣的品类才展示) + List _discountRows(GlobalStore store) { + final isNormalUser = store.meInfo == null || store.meInfo?.vipLevel == 0; + final isVip = store.isVIP; + final String discountText; + if ((_video.coins ?? 0) < (_video.originCoins ?? 0) && isVip) { + discountText = '您当前享受${store.meInfo?.payVidDiscount}折优惠'; + } else if (isVip) { + discountText = '您当前不享受免费观看'; + } else { + discountText = '您当前不享受折扣优惠'; + } + return [ + Row( + children: [ + const Text('作品原价', + style: TextStyle(color: Color(0xE5FFFFFF), fontSize: 14)), + const Spacer(), + Text( + '${_video.originCoins ?? 0} 金币', + style: TextStyle( + color: const Color(0x8CFFFFFF), + fontSize: 14, + decoration: isNormalUser ? null : TextDecoration.lineThrough, + decorationColor: const Color(0x8CFFFFFF), + ), + ), + ], + ), + 18.sizeBoxH, + Row( + children: [ + GradientText( + discountText, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + gradient: const LinearGradient( + colors: [Color(0xffAC7E6E), Color(0xffA4634D)]), + ), + const Spacer(), + GestureDetector( + onTap: () => pushToWalletPage(), + child: GradientText( + isVip ? '升级会员免费看' : '购买VIP免费看', + gradient: const LinearGradient( + colors: [Color(0xffAC7E6E), Color(0xffA4634D)]), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + decoration: TextDecoration.underline, + decorationColor: Color(0xffA4634D), + ), + ), + ), + ], + ), + 18.sizeBoxH, + ]; + } + + Widget _button(String text, + {required Color textColor, + required BoxDecoration decoration, + required VoidCallback onTap}) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onTap, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: decoration, + child: Text( + text, + style: TextStyle( + color: textColor, fontSize: 16, fontWeight: FontWeight.w500), + ), + ), + ); + } + + void _onPay(bool enough) async { + if (!enough) { + await CoinPayBottomSheet.show(); + return; + } + //黄游(SEED_LINK)和普通视频都按单片算,动漫按整部算 + final type = + (!_isSeedLink && _isCartoon) ? ProductType.mediaAll : ProductType.media; + await PayManager().buy( + _video.id, + type, + source: 'video_buy', + goldVideoCouponNum: 0, + jumpWalletOnInsufficient: false, + onSuccess: (data) { + _video.vidStatus?.hasPaid = true; + Get.back(result: true); + }, + onFailure: (data) { + if (data?.code == 8000) { + CoinPayBottomSheet.show(); + } + }, + ); + } +} diff --git a/lib/alert/video/video_line_menu_alert.dart b/lib/alert/video/video_line_menu_alert.dart new file mode 100644 index 0000000..0a77da2 --- /dev/null +++ b/lib/alert/video/video_line_menu_alert.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; + +import '../../config/address.dart'; +import '../../hj_model/splash/domain_source_model.dart'; +import '../../tools_base/global_store/store.dart'; +import '../mine/vip_level_dialog.dart'; + +class VideoLineMenuAlert extends StatelessWidget { + /// 返回 true 表示切换了线路,调用方据此重新起播 + static Future show() async { + return await Get.bottomSheet(VideoLineMenuAlert()) ?? false; + } + + VideoLineMenuAlert({super.key}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Container( + width: screen.screenWidth, + padding: EdgeInsets.only(bottom: screen.paddingBottom), + decoration: BoxDecoration( + color: Color(0xff0F0F0F), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 18.sizeBoxH, + const SheetHandleBar(), + 4.sizeBoxH, + ...Address.cdnAddressLists.map(_lineItem), + ], + ), + ), + ); + } + + Widget _lineItem(Domain domain) { + final isSelected = domain.url == Address.cdnAddress; + return InkWell( + enableFeedback: false, + onTap: () { + if (!globalStore.isVIP) { + showVipLevelDialog("该线路仅限会员使用\n\n" + "开通会员立享 极速观影体验"); + } else if (isSelected) { + Get.back(); + } else { + Address.cdnAddress = domain.url; + Get.back(result: true); // 带回切换结果,由调用方重新起播 + } + }, + child: Container( + height: 56, + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Center( + child: Text( + domain.desc ?? "", + style: TextStyle( + fontSize: 14, + color: isSelected + ? Color(0xfff68804) + : Colors.white.withValues(alpha: 0.45), + fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, + ), + ), + ), + ), + Container( + color: Colors.white.withValues(alpha: 0.05), + height: 1, + ), + ], + ), + ), + ); + } +} diff --git a/lib/alert/video/vip_acg_dialog.dart b/lib/alert/video/vip_acg_dialog.dart new file mode 100644 index 0000000..91accf5 --- /dev/null +++ b/lib/alert/video/vip_acg_dialog.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/common_dialog.dart'; + +/// 动漫/图集的 VIP 门槛提示弹窗:只有文案和一个开通按钮 +class VipACGDialog extends StatelessWidget { + const VipACGDialog({super.key}); + + @override + Widget build(BuildContext context) { + return CommonDialog( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "温馨提示", + style: TextStyle( + fontSize: 20, + color: Colors.white.withValues(alpha: .9), + fontWeight: FontWeight.w500), + ), + 24.sizeBoxH, + Text( + "本内容需要开通VIP会员\n开通会员 即可解锁查看完整版", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.white.withValues(alpha: .45), + height: 2), + ), + 24.sizeBoxH, + //开通按钮。margin 留在 InkWell 外,否则左右各 12 的空白会被算进点击热区 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: InkWell( + enableFeedback: false, + onTap: () => pushToWalletPage(), + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3)), + child: const Text( + "立即开通", + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w500), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/alert/vip_guide/guide_bottom_sheet.dart b/lib/alert/vip_guide/guide_bottom_sheet.dart new file mode 100644 index 0000000..4a26f70 --- /dev/null +++ b/lib/alert/vip_guide/guide_bottom_sheet.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; + +import '../../hj_page/mine/mine_vip/pay_order_source.dart'; +import '../../hj_page/mine/mine_vip/vip_support_model.dart'; +import '../../hj_page/mine/widgets/gradient_text.dart'; +import 'guide_common.dart'; +import 'guide_config.dart'; +import 'guide_manager.dart'; + +/// 吸底型付费引导弹窗(通用壳):横条 + 标题 + [badge] + 主图 + 副文案 + 开通按钮, +/// 首页/播放页各自传 title/subtitle/buttonText 组装 +class GuideBottomSheet extends StatelessWidget { + final VipProductModel card; + final Widget title; // 各场景自定义 + final Widget? badge; // 标题下方的小标签(免费次数场景用),不传不占位 + final Widget subtitle; // 主图下方文案 + final String buttonText; + final PaySourcePage sourcePage; + final String cover; // 场景下发的主图,相对路径由 ImageCacheManager 拼域名 + const GuideBottomSheet({ + super.key, + required this.card, + required this.title, + this.badge, + required this.subtitle, + required this.buttonText, + required this.sourcePage, + required this.cover, + }); + + /// [scene] 决定主图/卡/下单来源,调用方须先用开关判过能弹 + /// 返回 true = 点了开通并已跳会员页(调用方据此别恢复播放) + static Future show({ + required GuideScene scene, + required Widget title, + required Widget subtitle, + Widget? badge, + String? buttonText, + }) async { + final card = await loadGuideCard(scene); + if (card == null) return false; // 配置有误不弹 + final goVip = await Get.bottomSheet( + GuideBottomSheet( + card: card, + title: title, + badge: badge, + subtitle: subtitle, + buttonText: buttonText ?? '立即开通 · 仅需¥${card.discountedPriceUI}', + sourcePage: guideSourcePage(scene), + cover: GuideManager().configOf(scene)?.cover ?? '', //已校验非空 + ), + isScrollControlled: true, + backgroundColor: Colors.transparent, + ); + return goVip ?? false; + } + + /// 播放页引导(长短视频共用文案):文案优先用下发的,没配走默认。 + /// [buttonText] 短视频传无价文案,长视频走默认带价文案 + static Future showVideo({String? buttonText}) { + final config = GuideManager().configOf(GuideScene.videoPreviewEnd); + return show( + scene: GuideScene.videoPreviewEnd, + title: Text( + config?.title?.isNotEmpty == true + ? config!.title! + : '该视频为VIP内容\n只能观看试看内容哦~', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, fontSize: 17, fontWeight: FontWeight.w600), + ), + subtitle: GradientText( + config?.description?.isNotEmpty == true + ? config!.description! + : '想要查看完整内容,需要开通会员', + gradient: + const LinearGradient(colors: [Colors.white, Color(0xffEFD394)]), + style: const TextStyle( + color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), + ), + buttonText: buttonText, + ); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Container( + width: double.infinity, + padding: EdgeInsets.fromLTRB(16, 18, 16, 18 + screen.paddingBottom), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xff0F0F0F), Colors.black]), + border: Border( + top: BorderSide( + color: Color(0x4DE4BB6D))), // rgba(228,187,109,0.3) + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SheetHandleBar(), + 18.sizeBoxH, + title, + if (badge != null) ...[18.sizeBoxH, badge!], + 18.sizeBoxH, + // 主图(场景下发) + SizedBox( + width: 270, + height: 122, + child: NetworkImageLoader( + imageUrl: cover, fit: BoxFit.fill, borderRadius: 8), + ), + 18.sizeBoxH, + subtitle, + 18.sizeBoxH, + // 开通按钮 + GestureDetector( + onTap: () => goVipDetail(card, sourcePage: sourcePage), + child: Container( + width: double.infinity, + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xffFFB03F), Color(0xffF2680C)]), + borderRadius: BorderRadius.circular(26), + boxShadow: [ + BoxShadow( + color: const Color(0x73F2680C), + blurRadius: 18, + offset: const Offset(0, 6)) + ], // rgba(242,104,12,0.45) + ), + child: Text(buttonText, + style: const TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w700)), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/alert/vip_guide/guide_common.dart b/lib/alert/vip_guide/guide_common.dart new file mode 100644 index 0000000..2410127 --- /dev/null +++ b/lib/alert/vip_guide/guide_common.dart @@ -0,0 +1,59 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/splash/domain_source_model.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +import '../../hj_page/main_page/provider/msg_provider.dart'; +import '../../hj_page/mine/mine_vip/mine_charge_vip_page.dart'; +import '../../hj_page/mine/mine_vip/pay_order_source.dart'; +import '../../hj_page/mine/mine_vip/vip_product_manager.dart'; +import '../../hj_page/mine/mine_vip/vip_support_model.dart'; +import '../../tools_base/debug_log.dart'; +import 'guide_config.dart'; +import 'guide_manager.dart'; + +/// 付费引导弹窗的共用逻辑:取卡 / 场景来源 / 跳会员页 + +/// 弹窗前置校验都收在这里,返回 null 即不弹:productId、cover 缺任一或卡不在列表里 +/// 都算配置有误,一律不弹、不做兜底回落。 +/// 顺带备好卡数据(有缓存走缓存),播放页可提前调一次,别暂停后才拉数据白停几百毫秒 +Future loadGuideCard(GuideScene scene) async { + final config = GuideManager().configOf(scene); + if (config == null) return null; // 开关关着 + if (config.productId?.isNotEmpty != true || + config.cover?.isNotEmpty != true) { + debugLog('paymentGuide', '${scene.value} 缺 productId 或 cover,配置有误不弹'); + return null; + } + if (vipProductManager.vipCards.isEmpty) + await vipProductManager.loadVipCards(); + final card = vipProductManager.vipCards + .firstWhereOrNull((e) => e.productID == config.productId); + if (card == null) + debugLog( + 'paymentGuide', '${scene.value} 配置的会员卡不在列表里,不弹: ${config.productId}'); + return card; +} + +/// 分层门槛:非 VIP + 未付费的 under7/over7 两档。只剩上新横幅在用,其余场景走开关 +bool isUnpayGuideUser() { + if (globalStore.isVIP) return false; + final status = MineMsgProvider().payTier?.status; + return status == PayTier.under7DayUnpay || status == PayTier.over7DayUnpay; +} + +/// 场景 → 下单来源页(透传 /mine/topay 的 sourcePage) +PaySourcePage guideSourcePage(GuideScene scene) => switch (scene) { + GuideScene.homeNewUser || + GuideScene.homeOldUser || + GuideScene.homeNewUserFreeTrial => + PaySourcePage.homeUserSegment, + GuideScene.vipCenter => PaySourcePage.vipCenter, + _ => PaySourcePage.videoBottomSheet, //播放页起播引导 / 返回挽留 + }; + +/// 开通 → 关弹窗 + 进会员页并选中该卡(落地页 VIP_CENTER 开着还会再弹一次,不去重) +void goVipDetail(VipProductModel card, {required PaySourcePage sourcePage}) { + Get.back(result: true); // 带回"去开通了",调用方据此不恢复播放 + Get.to( + () => MineChargeVipPage(vipID: card.productID, sourcePage: sourcePage)); +} diff --git a/lib/alert/vip_guide/guide_config.dart b/lib/alert/vip_guide/guide_config.dart new file mode 100644 index 0000000..b403389 --- /dev/null +++ b/lib/alert/vip_guide/guide_config.dart @@ -0,0 +1,63 @@ +import 'package:get/get.dart'; + +/// 付费引导场景(ping 下发 paymentGuide.scenes 的 key) +enum GuideScene { + homeNewUser('HOME_NEW_USER'), //首页新用户引导(吸底) + homeOldUser('HOME_OLD_USER'), //首页老用户引导(吸底) + //新用户免费次数将尽引导(GuideFreeTrialSheet)。key 是 HOME_ 开头,但实际触发在播放页: + //newUnpay 用免费次数看视频且停留满5分钟 + homeNewUserFreeTrial('HOME_NEW_USER_FREE_TRIAL'), + //文档叫「试看结束引导」,实际是需付费视频起播3秒的前置引导(真试看结束是 BuyVipAlert 硬拦截,不受开关管) + videoPreviewEnd('VIDEO_PREVIEW_END'), + videoBack('VIDEO_BACK'), //退出视频挽留 + vipCenter('VIP_CENTER'), //会员中心引导 + //会员内容上新:前端不读这个开关,横幅按分层 + 独立接口判断(见 GuidePushBanner),此项只为枚举完整 + vipContentUpdate('VIP_CONTENT_UPDATE'); + + final String value; + const GuideScene(this.value); + + /// 后端 key → 枚举,不认识的返回 null + static GuideScene? from(String? value) => values.firstWhereOrNull((e) => e.value == value); +} + +/// 付费引导配置(/ping/domain 的 paymentGuide 字段) +/// 场景弹不弹全看这里的开关,触发时不再另发查询请求 +class GuideConfig { + bool enabled = false; //全局开关,false 时所有场景都不展示 + final scenes = {}; + + GuideConfig.fromJson(Map? json) { + json ??= {}; + enabled = json['enabled'] ?? false; + final map = json['scenes']; + if (map is! Map) return; + map.forEach((key, value) { + final scene = GuideScene.from('$key'); //前端不认识的场景直接丢 + if (scene != null && value is Map) { + scenes[scene] = GuideSceneConfig.fromJson(Map.from(value)); + } + }); + } +} + +/// 单场景配置:enabled && show 都为 true 才弹,触发几次弹几次,前端不改这两个值。 +/// 后端另下发的 configId / style / durationSeconds 前端用不上,没解析 +class GuideSceneConfig { + bool enabled = false; + bool show = false; + String? title; + String? description; + String? cover; //弹窗主图(设计稿那张「限时永久卡」),不是会员卡图 + String? productId; //会员商品 id:按它取卡,跳会员页也选中它 + + GuideSceneConfig.fromJson(Map? json) { + json ??= {}; + enabled = json['enabled'] ?? false; + show = json['show'] ?? false; + title = json['title']; + description = json['description']; + cover = json['cover']; + productId = json['productId']; + } +} diff --git a/lib/alert/vip_guide/guide_countdown_dialog.dart b/lib/alert/vip_guide/guide_countdown_dialog.dart new file mode 100644 index 0000000..cc3f634 --- /dev/null +++ b/lib/alert/vip_guide/guide_countdown_dialog.dart @@ -0,0 +1,267 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../assets_tool/images.dart'; +import '../../hj_page/mine/mine_vip/pay_order_source.dart'; +import '../../hj_page/mine/mine_vip/vip_support_model.dart'; +import '../../hj_page/mine/widgets/gradient_text.dart'; +import 'guide_common.dart'; +import 'guide_config.dart'; +import 'guide_manager.dart'; + +/// 优惠倒计时:时长前端写死,起点由 app 启动时 reset 一次,回前台不重置 +class PromoCountdown { + static const Duration _duration = Duration(hours: 2); + static DateTime? _deadline; + + static DateTime get _end => _deadline ??= DateTime.now().add(_duration); + + /// 剩余时长,归零停在 0 + static Duration get remain { + final d = _end.difference(DateTime.now()); + return d.isNegative ? Duration.zero : d; + } + + /// app 启动时调一次(见 main),进程存活期间自然倒数 + static void reset() => _deadline = DateTime.now().add(_duration); +} + +/// 优惠倒计时弹窗(居中):主图骑顶 + 优惠文案 + 倒计时 + 开通按钮。 +/// 长视频返回挽留 / 进会员页时触发 +class GuideCountdownDialog extends StatelessWidget { + final VipProductModel card; + final ValueChanged? + onConfirm; // 默认跳会员页选卡;已在会员页的场景自己处理(关弹窗+就地选中该卡) + final PaySourcePage sourcePage; + final String cover; // 场景下发的主图,相对路径由 ImageCacheManager 拼域名 + const GuideCountdownDialog({ + super.key, + required this.card, + required this.sourcePage, + required this.cover, + this.onConfirm, + }); + + /// [scene] 决定主图/卡/下单来源,调用方须先用开关判过能弹 + /// 返回 true = 点了「立即开通」(点X/点蒙层/没弹成都是 false),返回挽留场景据此决定要不要放行返回 + static Future show( + {required GuideScene scene, + ValueChanged? onConfirm}) async { + final card = await loadGuideCard(scene); + if (card == null) return false; // 配置有误不弹 + final confirmed = await Get.dialog( + GuideCountdownDialog( + card: card, + sourcePage: guideSourcePage(scene), + cover: GuideManager().configOf(scene)?.cover ?? '', //已校验非空 + onConfirm: onConfirm, + ), + ); + return confirmed ?? false; + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 311, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.topCenter, + children: [ + // 主体(顶部留 61 给主图骑顶溢出) + Container( + margin: const EdgeInsets.only(top: 61), + padding: const EdgeInsets.fromLTRB(24, 80, 24, 24), + decoration: BoxDecoration( + color: const Color(0xff0F0F0F), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 现在下单获得[优惠]福利:「优惠」金渐变、更大号 + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + const Text('现在下单获得', + style: TextStyle( + color: Colors.white, + fontSize: 19, + fontWeight: FontWeight.w800)), + 2.sizeBoxW, + const GradientText( + '优惠', + gradient: LinearGradient(colors: [ + Color(0xffFFE3A0), + Color(0xffF0A93C) + ]), + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w800), + ), + 2.sizeBoxW, + const Text('福利', + style: TextStyle( + color: Colors.white, + fontSize: 19, + fontWeight: FontWeight.w800)), + ], + ), + 15.sizeBoxH, + const _CountdownRow(), + 15.sizeBoxH, + GestureDetector( + onTap: () { + if (onConfirm != null) { + onConfirm!(card); + } else { + goVipDetail(card, sourcePage: sourcePage); + } + }, + child: Container( + width: 269, + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: const LinearGradient(colors: [ + Color(0xffFFB03F), + Color(0xffF2680C) + ]), + borderRadius: BorderRadius.circular(26), + boxShadow: [ + BoxShadow( + color: const Color(0x73F2680C), + blurRadius: 18, + offset: const Offset(0, 6)) + ], // rgba(242,104,12,0.45) + ), + child: Text( + '立即开通 · 仅需¥${card.discountedPriceUI}', + style: const TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + ), + // 主图骑在顶部 + Positioned( + top: 0, + child: SizedBox( + width: 270, + height: 122, + child: NetworkImageLoader( + imageUrl: cover, fit: BoxFit.fill, borderRadius: 8), + ), + ), + ], + ), + ), + 20.sizeBoxH, + GestureDetector( + onTap: () => Get.back(), + child: Image.asset("close_button.png".commonImgPath, width: 32), + ) + ], + ), + ), + ); + } +} + +/// 距优惠结束 HH:MM:SS:每秒只重建这一行,归零后停表 +class _CountdownRow extends StatefulWidget { + const _CountdownRow(); + + @override + State<_CountdownRow> createState() => _CountdownRowState(); +} + +class _CountdownRowState extends State<_CountdownRow> { + final _remain = ValueNotifier(PromoCountdown.remain); + Timer? _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (t) { + final r = PromoCountdown.remain; + if (r == Duration.zero) t.cancel(); //归零停表(含弹窗打开时就已过期) + if (r.inSeconds == _remain.value.inSeconds) return; //秒数没变不通知 + _remain.value = r; + }); + } + + @override + void dispose() { + _timer?.cancel(); + _remain.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('距优惠结束', + style: TextStyle( + color: Color(0xffB8AE9C), + fontSize: 12, + fontWeight: FontWeight.w500)), + 8.sizeBoxW, + ValueListenableBuilder( + valueListenable: _remain, + builder: (_, r, __) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + _timeCell(r.inHours), + _colon, + _timeCell(r.inMinutes % 60), + _colon, + _timeCell(r.inSeconds % 60), + ], + ), + ), + ], + ); + } + + // 时/分/秒块 + Widget _timeCell(int v) => Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xffE23B2E), + borderRadius: BorderRadius.circular(6)), + child: Text( + v.toString().padLeft(2, '0'), + style: const TextStyle( + color: Colors.white, fontSize: 14, fontWeight: FontWeight.w700), + ), + ); + + Widget get _colon => const Padding( + padding: EdgeInsets.symmetric(horizontal: 3), + child: Text(':', + style: TextStyle( + color: Color(0xffE85B4F), + fontSize: 14, + fontWeight: FontWeight.w700)), + ); +} diff --git a/lib/alert/vip_guide/guide_free_trial_sheet.dart b/lib/alert/vip_guide/guide_free_trial_sheet.dart new file mode 100644 index 0000000..f16d25b --- /dev/null +++ b/lib/alert/vip_guide/guide_free_trial_sheet.dart @@ -0,0 +1,63 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; + +import '../../hj_page/mine/widgets/gradient_text.dart'; +import '../../hj_utils/free_play_manager.dart'; +import 'guide_bottom_sheet.dart'; +import 'guide_config.dart'; +import 'guide_manager.dart'; + +/// 新用户免费次数将尽的付费引导(吸底,复用 [GuideBottomSheet] 通用壳,多一条剩余次数红标)。 +/// 触发:newUnpay 用免费次数看视频、次数将尽且停留满5分钟;卡和主图都取 HOME_NEW_USER_FREE_TRIAL 场景配置 +class GuideFreeTrialSheet { + GuideFreeTrialSheet._(); + + static const scene = GuideScene.homeNewUserFreeTrial; + + /// 剩余免费次数。+1 是为了和右下角「免费视频剩余N次」角标同口径——都含正在看的这一次 + /// (remain 在 _initPlayer 起手就被 useFreePlay 扣过一次了,不 +1 会比角标少 1) + static int get remainCount => (FreePlayManager().remain?.watchCount ?? 0) + 1; + + /// 次数将尽:剩 2 次及以内才值得打扰 + static bool get isCountLow => remainCount <= 2; + + /// 返回 true = 点了开通并已跳会员页 + static Future show() { + final remain = remainCount; + final total = FreePlayManager().remain?.total; + final description = GuideManager().configOf(scene)?.description; //副文案走下发,没配用默认 + return GuideBottomSheet.show( + scene: scene, + title: EasyRichText( + '免费观看次数 即将用尽!', + textAlign: TextAlign.center, + defaultStyle: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w500), + patternList: [ + EasyRichTextPattern( + targetString: '即将用尽!', + matchWordBoundaries: false, // 纯中文无 \b 词边界,关掉才能匹配上 + style: const TextStyle(color: Color(0xffE94335), fontSize: 17, fontWeight: FontWeight.w500), + ), + ], + ), + // 剩余免费次数红标 + badge: Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20), + decoration: BoxDecoration( + color: const Color(0x1AE94335), // rgba(233,67,53,0.1) + border: Border.all(color: const Color(0x33E7472A)), // rgba(231,71,42,0.2) + borderRadius: BorderRadius.circular(20), + ), + child: Text( + total != null ? '剩余免费次数 $remain/$total次' : '剩余免费次数 $remain次', + style: const TextStyle(color: Color(0xffE94335), fontSize: 15), + ), + ), + subtitle: GradientText( + description?.isNotEmpty == true ? description! : '开通会员即可 畅看 "VIP全部视频"', + gradient: const LinearGradient(colors: [Colors.white, Color(0xffEFD394)]), + style: const TextStyle(color: Colors.white, fontSize: 19, fontWeight: FontWeight.w500), + ), + ); + } +} diff --git a/lib/alert/vip_guide/guide_home_dialog.dart b/lib/alert/vip_guide/guide_home_dialog.dart new file mode 100644 index 0000000..e8f055d --- /dev/null +++ b/lib/alert/vip_guide/guide_home_dialog.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/splash/domain_source_model.dart'; +import 'package:hgdj/hj_page/home/activity_float/home_activity_float.dart'; +import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/fly_to_overlay.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +/// 支付分层首页弹窗:展示后端 homePage 图,点击跳会员页并选中 config.vipCard。 +/// 倒计时读 config.lastDiscountTime,秒级刷新由 MineMsgProvider().tick 驱动,过期自动停 +class GuideHomeDialog extends StatefulWidget { + final PayTierConfig config; + + const GuideHomeDialog({super.key, required this.config}); + + @override + State createState() => _GuideHomeDialogState(); +} + +class _GuideHomeDialogState extends State { + final _posterKey = GlobalKey(); // 关闭动画的起飞位置 + + /// 关闭中:① 防连点,pop 过渡期弹窗还在树上,再点会把首页也 pop 掉 + /// ② 藏掉原海报,否则它随弹窗淡出、和飞行副本重影 + bool _closing = false; + + PayTierConfig get config => widget.config; + + /// 关闭:海报缩小飞进右下角浮窗,告诉用户同款入口收在哪 + void _close() { + if (_closing) return; + _closing = true; + final target = _floatRect(); + if (target != null) { + FlyToOverlay.play( + context: context, + sourceKey: _posterKey, + target: target, + // 副本只放海报,倒计时胶囊不跟着飞(缩到 60 宽也看不清) + // 占位给透明:没命中内存缓存时别飞一个灰块过去 + child: NetworkImageLoader( + imageUrl: config.homePage ?? "", + fit: BoxFit.fill, + borderRadius: 0, + placeHolderWidget: const SizedBox(), + ), + ); + } + setState(() {}); // 原海报立刻消失,只留副本在飞 + Get.back(); + } + + /// 落点:容器右下角一块浮窗大小的区域(浮窗都 bottomRight 对齐)。 + /// 认容器不认轮播子项——子项会轮到屏幕外,容器位置才稳定。 + /// 浮窗没展示时容器不存在,返回 null → 直接关不飞 + Rect? _floatRect() { + final box = homeFloatKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return null; + final origin = box.localToGlobal(Offset.zero); + const w = 60.0, h = 66.0; // 与 _LayeredFloatView 的尺寸一致 + return Rect.fromLTWH( + origin.dx + box.size.width - w, origin.dy + box.size.height - h, w, h); + } + + @override + Widget build(BuildContext context) { + // 点蒙层、返回键都走 _close,保证任何关闭方式都有飞入动画 + // (canPop:false 只拦 maybePop,_close 里的 Get.back() 是强制 pop,不会被自己拦住) + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _close(); + }, + child: Center( + // 关闭中内容交给飞行副本。必须同时 IgnorePointer: + // Opacity(0) 照样吃手势,退出动画那百来毫秒点到海报会再 pop 一次并跳会员页 + child: IgnorePointer( + ignoring: _closing, + child: Opacity( + opacity: _closing ? 0 : 1, + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: GestureDetector( + // 只回传"去开通",跳会员页交给调用方(_showPayDialog)——它要 await 到从会员页 + // 返回才继续弹窗链,否则后面的首页吸底引导会弹在会员页上 + onTap: () { + if (_closing) return; // 防连点:pop 过渡期弹窗还在树上,再点会把首页也 pop 掉 + _closing = true; + Get.back(result: true); + }, + child: AspectRatio( + aspectRatio: 3 / 4, + child: Stack( + key: _posterKey, + fit: StackFit.expand, + children: [ + NetworkImageLoader( + imageUrl: config.homePage ?? "", + fit: BoxFit.fill, + borderRadius: 0, + placeHolderWidget: + Container(color: const Color(0x00000000)), + ), + // 倒计时胶囊:海报右上角,听 tick 每秒刷新,过期自动收起 + Positioned( + top: 0, + right: 0, + child: ValueListenableBuilder( + valueListenable: MineMsgProvider().tick, + builder: (_, __, ___) => + config.hasDiscountCountdown + ? _buildCountdownPill() + : const SizedBox.shrink(), + ), + ), + ], + ), + ), + ), + ), + 20.sizeBoxH, + GestureDetector( + onTap: _close, + child: Image.asset("close_button.png".commonImgPath, + width: 32), + ) + ], + ), + ), + ), + ), + ), + ); + } + + /// 倒计时胶囊:红渐变 + 奶白边,HH:MM:SS + Widget _buildCountdownPill() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xffE03017), Color(0xffF27952)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xffFFFAEE), width: 0.5), + ), + child: Text( + '${config.discountHour}:${config.discountMin}:${config.discountSec}', + style: const TextStyle( + color: Color(0xffFFF0D1), + fontSize: 20, + fontWeight: FontWeight.w600), + ), + ); + } +} diff --git a/lib/alert/vip_guide/guide_manager.dart b/lib/alert/vip_guide/guide_manager.dart new file mode 100644 index 0000000..7389919 --- /dev/null +++ b/lib/alert/vip_guide/guide_manager.dart @@ -0,0 +1,56 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/splash/domain_source_model.dart'; + +import '../../hj_page/main_page/provider/msg_provider.dart'; +import 'guide_config.dart'; + +/// 付费引导开关(全站唯一数据源) +/// +/// 配置随 /ping/domain 及 /ping/domain/refresh?keys=paymentGuide 下发后缓存在内存, +/// 首页/播放页/会员中心触发时直接读,不再临时请求接口。 +/// +/// 弹不弹只认后端下发的 enabled && show:本地不去重、不改这两个值、也不上报曝光, +/// 触发几次弹几次,要收敛得由后端把 show 改成 false;没下发配置则一律不弹。 +/// (VIP_CONTENT_UPDATE 不归这里管,它走独立接口 + 曝光回执,见 GuidePushBanner) +class GuideManager { + static final GuideManager _instance = GuideManager._(); + factory GuideManager() => _instance; + GuideManager._(); + + GuideConfig? _config; + + /// ping / ping refresh 下发时写入。字段缺失(后端未下发)时保留上一次配置,不清空 + void update(dynamic json) { + if (json is! Map) return; + _config = GuideConfig.fromJson(Map.from(json)); + } + + /// 场景配置:全局开关关掉、场景没配或没启用都返回 null + GuideSceneConfig? configOf(GuideScene scene) { + final all = _config; + if (all == null || !all.enabled) return null; + final config = all.scenes[scene]; + return config?.enabled == true ? config : null; + } + + /// 能不能弹:全局开关 + 场景启用([configOf])之外,还要后端本次放行 + bool canShow(GuideScene scene) => configOf(scene)?.show == true; + + /// 首页新/老用户二选一:**按本地分层选**,newUnpay 走新用户场景,其余走老用户; + /// 选中的那个不可展示就不弹,不回退到另一个——分层不匹配的文案给错人比不弹更糟。 + /// (以前只按 show 挑,后端两个都下发 true 时老用户会命中新人场景) + /// + /// ⚠️ 分层为 null 时必须退回按 show 挑,不能当成老用户:payTier 由 MainLogic.onInit 的 + /// refreshPayPopup 异步拉,而本 getter 在首帧后的弹窗链里就被读了——首页没别的弹窗时 + /// 请求还没回来,硬判成老用户会让新用户永远错过新人引导,且 _adShown 保证不会再有第二次 + GuideScene? get homeScene { + final tier = MineMsgProvider().payTier?.status; + if (tier == null) + return [GuideScene.homeNewUser, GuideScene.homeOldUser] + .firstWhereOrNull(canShow); + final scene = tier == PayTier.newUnpay + ? GuideScene.homeNewUser + : GuideScene.homeOldUser; + return canShow(scene) ? scene : null; + } +} diff --git a/lib/alert/vip_guide/guide_push_banner.dart b/lib/alert/vip_guide/guide_push_banner.dart new file mode 100644 index 0000000..44dbe14 --- /dev/null +++ b/lib/alert/vip_guide/guide_push_banner.dart @@ -0,0 +1,436 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:uuid/uuid.dart'; + +import '../../assets_tool/images.dart'; +import '../../hj_page/home/home_cell_style/video_simple_cell.dart'; +import '../../hj_page/main_page/main_page.dart'; +import '../../hj_page/mine/widgets/gradient_text.dart'; +import '../../hj_utils/api_service/common_service.dart'; +import '../../hj_utils/light_model.dart'; +import '../../hj_utils/screen.dart'; +import '../../hj_utils/store_keys.dart'; +import '../../routers/jump_router.dart'; +import '../../tools_base/debug_log.dart'; +import '../../tools_base/global_store/store.dart'; +import 'guide_common.dart'; +import 'guide_push_model.dart'; +import 'timed_popup_manager.dart'; + +/// VIP「特享内容」顶部推送横幅(下滑入,上滑收起) +/// +/// 用 app 满 [_delay] 后,每次回到 MainPage 拉一次接口,show=true 才弹。 +/// [start] 的定时任务兜住「到点时一直停在 MainPage、没有 pop 事件」这种边界, +/// 其余靠 [onBackToMainPage]。调度走 [TimedPopupManager],与其它引导互斥 +class GuidePushBanner extends StatelessWidget { + final GuidePushModel model; + const GuidePushBanner({super.key, required this.model}); + + static const _scene = 'VIP_CONTENT_UPDATE'; //接口场景标识 + static const _delay = Duration(minutes: 3); //进 app 多久后才开始推 + + //进 app 的时刻,MainPage 每次创建都重置。null = start() 还没跑 + static DateTime? _enterAt; + static bool _showing = false; //显示中:自身关闭引发的 didPopNext 不能又弹一次 + static bool _fetching = false; //拉取中:快速进出页面会连续触发,防并发重复请求 + + //够不够时长。算墙上时间,切后台那段也算(要的是用满3分钟,不是前台累积) + static bool get _overDelay => _enterAt != null && DateTime.now().difference(_enterAt!) >= _delay; + + /// 只对 under7/over7 未付费、在 MainPage 栈顶、自己没在显示时弹 + static bool get _canShow => !_showing && isUnpayGuideUser() && Get.currentRoute == MainPage.routeName; + + /// MainPage 创建时调一次 + static void start() { + _enterAt = DateTime.now(); + TimedPopupManager().schedule( + type: TimedPopupType.guidePushBanner, + delay: _delay, + //计时无条件起(见 schedule 注释),条件到点才判,和 onBackToMainPage 走同一套 + canShow: () => _canShow, + onShow: _fetchThenShow, + ); + } + + /// MainPage 销毁时调:定时任务不留着空跑 + static void stop() => TimedPopupManager().cancel(TimedPopupType.guidePushBanner); + + /// 回到一级页面:满 [_delay] 后每次都拉一次接口 + static Future onBackToMainPage() { + if (!_overDelay) return Future.value(); + return TimedPopupManager().trigger(canShow: () => _canShow, onShow: _fetchThenShow); + } + + /// 拉配置 → show=true 且本地没展示过这批才弹。调用方已在互斥区内,不再嵌套 trigger + static Future _fetchThenShow() async { + if (_fetching || !_canShow) return; + _fetching = true; + try { + final model = await CommonService.fetchPaymentGuide(_scene); + if (model?.show != true || model!.videos.isEmpty) return; + + // 回执没落库时服务端会重复下发同一批:挡掉别重复弹,顺便补报那次没成功的曝光 + // (只挡不补的话服务端永远认为没展示过,就卡死在「本地挡着、服务端一直发」) + final record = await _readRecord(); + if (record != null && record.isSameAs(model)) { + if (!record.reported) await _report(record); + return; + } + + if (!_canShow) return; //拉取期间可能已经跳去别的页面 + _showing = true; + final newRecord = ShownRecord.from(model, const Uuid().v4()); + await _saveRecord(newRecord); //先落本地再弹,并发的后一次才挡得住 + final closed = _showBanner(model); //generalDialog 一调用就已入栈 = 展示成功 + _report(newRecord); //不 await:上报不挡展示 + //等关闭,期间 _showing 保持 true:横幅自己是 MainPage 上的一个 route, + //关闭会触发 didPopNext,不挡住就立刻又弹一次 + await closed; + } catch (e) { + debugLog('guidePushBanner', e.toString()); + } finally { + _showing = false; + _fetching = false; + } + } + + /// 上报已展示。失败不改 reported,留到下次拉到同一批时重报(幂等,必须用同一个 requestId) + static Future _report(ShownRecord record) async { + try { + final success = await CommonService.reportPaymentGuideImpression( + configId: record.configId, + scene: _scene, + contentVersion: record.version, + requestId: record.requestId, + ); + if (!success) return; + record.reported = true; + await _saveRecord(record); + } catch (e) { + debugLog('guidePushBanner', '曝光上报失败,留待下次重报: $e'); + } + } + + /// 记录 key 带 uid,换号互不影响。只留最近一条,更早的版本服务端不会再下发 + static String get _recordKey => '${StoreKeys.VIP_PUSH_SHOWN_VERSION_PREFIX}${globalStore.meInfo?.uid ?? 0}'; + + static Future _readRecord() async { + final raw = await lightKV.getString(_recordKey); + if (raw == null || raw.isEmpty) return null; + try { + return ShownRecord.fromJson(jsonDecode(raw)); + } catch (_) { + return null; //旧格式/脏数据,当没记录处理 + } + } + + static Future _saveRecord(ShownRecord record) => lightKV.setString(_recordKey, jsonEncode(record.toJson())); + + static Future _showBanner(GuidePushModel model) { + return Get.generalDialog( + barrierDismissible: false, //点遮罩不关,只认上滑和横幅里的按钮 + barrierLabel: 'GuidePushBanner', + barrierColor: Colors.black.withValues(alpha: .5), + transitionDuration: const Duration(milliseconds: 320), + pageBuilder: (_, __, ___) => GuidePushBanner(model: model), + transitionBuilder: (_, animation, __, child) => SlideTransition( + position: Tween(begin: const Offset(0, -1), end: Offset.zero).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ), + ), + child: child, + ), + ); + } + + static const _maxCount = 4; //最多展示几个封面 + + List get videos => model.videos.take(_maxCount).toList(); + + // ===== 尺寸按比例算,不写死高度,任何屏宽都不溢出 ===== + static const _cellRatio = 76 / 55; //单卡整体比例(设计稿 76×55,含底部标题) + static const _maxContentWidth = 500.0; //超宽屏限宽居中,不让封面无限拉大 + + double get _contentWidth => Get.width < _maxContentWidth ? Get.width : _maxContentWidth; + + //以下几个常量要和 _buildVideoCard 的实际布局一致,改一处就同步改 + static const _cardMargin = 6.0; //卡片左右外边距 + static const _cardPadding = 6.0; //卡片内左右边距 + static const _itemSpacing = 4.0; //封面之间的间距 + static const _headerSpace = 36.0; //卡片内顶部留给标签/标题的高度 + + double get _itemWidth => (_contentWidth - _cardMargin * 2 - _cardPadding * 2 - _itemSpacing * (_maxCount - 1)) / _maxCount; + + double get _itemHeight => _itemWidth / _cellRatio; + + /// 卡片高 = 顶部让位 + 单卡高 + 底部留白 + double get _cardHeight => _headerSpace + _itemHeight + 16; + + @override + Widget build(BuildContext context) { + //Material 只包横幅本体,铺满全屏没必要还白吃一层点击 + return Align( + alignment: Alignment.topCenter, + child: Material( + color: Colors.transparent, + child: _SwipeUpToDismiss( + child: Container( + width: double.infinity, + padding: EdgeInsets.only(top: Get.mediaQuery.padding.top), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(20)), + border: const Border(bottom: BorderSide(color: Color(0xffB71E03))), + ), + child: Center( + heightFactor: 1, //不加会撑满全屏高度,横幅变成一整块黑屏 + child: SizedBox( + width: _contentWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + //标签/标题压在卡片上部,故同层叠 + Stack(children: [_buildVideoCard(), _buildHeader()]), + 19.sizeBoxH, + _buildVipButton(), + 21.sizeBoxH, + ], + ), + ), + ), + ), + ), + ), + ); + } + + /// 视频卡片:标签/标题压在上半部,视频排下半部。 + /// GridView 固定 4 列,不足 4 条时靠左排,封面不会被拉大撑破卡片 + Widget _buildVideoCard() { + return Padding( + padding: const EdgeInsets.only(left: _cardMargin, right: _cardMargin, top: 19), + child: Container( + height: _cardHeight, + padding: const EdgeInsets.only(left: _cardPadding, right: _cardPadding, top: _headerSpace), + decoration: BoxDecoration( + color: const Color(0xffB71E03).withValues(alpha: .1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xffB71E03)), + ), + child: GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _maxCount, + crossAxisSpacing: _itemSpacing, + childAspectRatio: _cellRatio, + ), + itemCount: videos.length, + itemBuilder: (_, index) => _buildVideoItem(videos[index]), + ), + ), + ); + } + + /// 复用首页视频 cell:不传 imgAspectRatio,封面撑满格子剩余高度,比例随格子走(裁切显示) + Widget _buildVideoItem(GuidePushVideo video) { + return VideoSimpleCell( + videoModel: video.toVideoModel(), //freeArea=true 顺带去掉右上角 VIP 角标 + textLines: 1, + isShowBottom: false, + titleFontSize: 8, + coverFontSize: 7, + onTap: () { + Get.back(); + //只传 id:转出来的 model 带 freeArea=true 会被播放页当成免费区, + //且 playTime<300 会被判成短视频跳到抖音页 + pushToVideoPage(videoId: video.id); + }, + ); + } + + /// 贴纸 + 标题底部对齐一行,标题走接口下发的 description,没配用默认文案 + Widget _buildHeader() { + return Positioned( + left: 25, + right: 27, + top: 4, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Image.asset('update_video.webp'.videoPath, width: 113), + //用 Expanded 不用 Spacer:Row 给非 flex 子节点的是无界约束,scaleDown 缩不动会溢出 + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: GradientText( + model.description?.isNotEmpty == true ? model.description! : 'VIP会员“特享内容”更新上架啦~', + gradient: const LinearGradient( + colors: [Color(0xffFFE8BE), Color(0xffE6B764)], + ), + style: const TextStyle(fontSize: 13), + ), + ), + ), + ], + ), + ); + } + + Widget _buildVipButton() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 39), + child: GestureDetector( + onTap: () { + Get.back(); + final action = model.action; //VIP_PRODUCT 带商品 id 进会员页,没配走默认 + pushToWalletPage(vipId: action?.type == 'VIP_PRODUCT' ? action?.value : null); + }, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + gradient: const LinearGradient(colors: [Color(0xffFFB03F), Color(0xffF2680C)]), + boxShadow: [ + BoxShadow( + color: const Color(0xffF2680C).withValues(alpha: .45), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: const Text( + '开通会员 · 查看劲爆视频', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } +} + +/// 上滑关闭:跟手位移 + 下拉阻尼 + 松手回弹,滑得够远或甩得够快直接 pop。 +/// 不用 Dismissible:它要等自己那套滑出动画跑完才回调,横幅都滑没了遮罩还杵在那 +class _SwipeUpToDismiss extends StatefulWidget { + final Widget child; + const _SwipeUpToDismiss({required this.child}); + + @override + State<_SwipeUpToDismiss> createState() => _SwipeUpToDismissState(); +} + +class _SwipeUpToDismissState extends State<_SwipeUpToDismiss> with SingleTickerProviderStateMixin { + static const _dismissRatio = 0.2; //上滑超过自身高度 20% 松手即关 + static const _flingVelocity = 700.0; //甩得够快就关,不管滑了多远 + static const _downDamping = 0.18; //往下拖的阻尼:横幅本来就贴着顶,只留一点点余量 + + late final AnimationController _springCtr = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + ); + late final CurvedAnimation _spring = CurvedAnimation(parent: _springCtr, curve: Curves.easeOutCubic); + + double _dragY = 0; //手指累计位移,负 = 往上 + + //回弹就是把当前位移按 _spring 收回 0 + double get _translateY => (_dragY < 0 ? _dragY : _dragY * _downDamping) * (1 - _spring.value); + + @override + void dispose() { + _spring.dispose(); + _springCtr.dispose(); + super.dispose(); + } + + //回弹途中又按下:接住当前位置继续跟手,别手指按着它还自顾自往回收 + void _onDragStart(DragStartDetails _) { + _dragY = _translateY; + _springCtr.value = 0; + } + + void _onDragUpdate(DragUpdateDetails details) => setState(() => _dragY += details.delta.dy); + + void _onDragEnd(DragEndDetails details) { + final velocity = details.primaryVelocity ?? 0; //上滑为负 + if (-_dragY > (context.size?.height ?? Get.height) * _dismissRatio || velocity < -_flingVelocity) { + //位移不复位就 pop,路由的反向动画从当前位置接着走,横幅和遮罩一块收走 + Get.back(); + } else { + _springCtr.forward(from: 0); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: _onDragStart, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: _onDragEnd, + child: AnimatedBuilder( + animation: _spring, + builder: (_, child) => Transform.translate(offset: Offset(0, _translateY), child: child), + child: widget.child, + ), + ); + } +} + +/// 本地展示记录:弹过哪批内容 + 曝光有没有报成功,存 lightKV(重启不丢) +class ShownRecord { + final String version; //contentVersion + final String configId; + final String requestId; //幂等 id,重报要用同一个 + final List videoIds; + bool reported; + + ShownRecord({ + required this.version, + required this.configId, + required this.requestId, + required this.videoIds, + this.reported = false, + }); + + factory ShownRecord.from(GuidePushModel model, String requestId) => ShownRecord( + version: model.contentVersion ?? '', + configId: model.configId ?? '', + requestId: requestId, + videoIds: model.videos.map((e) => e.id ?? '').toList(), + ); + + factory ShownRecord.fromJson(Map json) => ShownRecord( + version: json['version'] ?? '', + configId: json['configId'] ?? '', + requestId: json['requestId'] ?? '', + videoIds: (json['videoIds'] as List?)?.map((e) => '$e').toList() ?? [], + reported: json['reported'] ?? false, + ); + + Map toJson() => { + 'version': version, + 'configId': configId, + 'requestId': requestId, + 'videoIds': videoIds, + 'reported': reported, + }; + + /// 同一批:版本号一致,或版本号变了但视频还是那几条(前后端不同步) + bool isSameAs(GuidePushModel model) { + if (version.isNotEmpty && version == model.contentVersion) return true; + final ids = model.videos.map((e) => e.id ?? '').toList(); + return ids.isNotEmpty && ids.join(',') == videoIds.join(','); + } +} diff --git a/lib/alert/vip_guide/guide_push_model.dart b/lib/alert/vip_guide/guide_push_model.dart new file mode 100644 index 0000000..d6a56ef --- /dev/null +++ b/lib/alert/vip_guide/guide_push_model.dart @@ -0,0 +1,73 @@ +import '../../hj_model/video_model.dart'; + +/// 付费引导弹窗配置(场景 VIP_CONTENT_UPDATE:VIP 内容上新顶部推送横幅) +/// 只解析前端用得到的字段;title/segment/style/cover/durationSeconds/videoIds 暂未使用 +class GuidePushModel { + bool? show; // false = 本次不展示(已展示过 / 无可用配置 / 无符合条件内容) + String? configId; + String? contentVersion; // 内容版本,上报回执时原样带回;也是本地去重的依据 + String? description; // 横幅标题文案 + String? productId; + GuidePushAction? action; // 按钮跳转配置 + List videos = []; // 最新 VIP 内容,最多 4 条 + + GuidePushModel.fromJson(Map? json) { + json ??= {}; + show = json['show']; + configId = json['configId']; + contentVersion = json['contentVersion']; + description = json['description']; + productId = json['productId']; + if (json['action'] is Map) { + action = GuidePushAction.fromJson(json['action']); + } + if (json['videos'] is List) { + videos = (json['videos'] as List).map((e) => GuidePushVideo.fromJson(e)).toList(); + } + } +} + +/// 弹窗按钮动作:type=VIP_PRODUCT 时 value 为会员商品 id +class GuidePushAction { + String? type; + String? value; + + GuidePushAction.fromJson(Map? json) { + json ??= {}; + type = json['type']; + value = json['value']; + } +} + +class GuidePushVideo { + String? id; + String? title; + String? cover; + String? coverThumb; // 缩略图,列表展示优先用它 + int? playTime; // 时长(秒) + int? playCount; // 播放量 + + GuidePushVideo.fromJson(Map? json) { + json ??= {}; + id = json['id']; + title = json['title']; + cover = json['cover']; + coverThumb = json['coverThumb']; + playTime = json['playTime']; + playCount = json['playCount']; + } + + /// 列表用封面:缩略图为空时回落大图 + String get showCover => coverThumb?.isNotEmpty == true ? coverThumb! : cover ?? ''; + + /// 转成 VideoModel,复用项目现成的视频 cell。 + /// freeArea=true 是为了让 cell 不画右上角 VIP 角标(见 VideoSimpleCell._buildLevelIcon), + /// ⚠️ 因此这个对象只能喂给 cell 渲染,不能传给播放页——那边会当成免费区、放过付费拦截 + VideoModel toVideoModel() => VideoModel() + ..id = id + ..title = title + ..cover = showCover + ..playTime = playTime + ..playCount = playCount + ..freeArea = true; +} diff --git a/lib/alert/vip_guide/timed_popup_manager.dart b/lib/alert/vip_guide/timed_popup_manager.dart new file mode 100644 index 0000000..00431f7 --- /dev/null +++ b/lib/alert/vip_guide/timed_popup_manager.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +/// 定时弹窗的任务标识:只服务 [TimedPopupManager.schedule] / [TimedPopupManager.cancel] 的去重与取消。 +/// 立即触发的场景(首页吸底、返回挽留、会员页倒计时等)走 trigger,不占枚举值。 +enum TimedPopupType { + freeWatchPay, // 新用户(newUnpay)免费次数观看满5分钟付费引导 + videoPayAhead, // 需付费视频起播3秒的前置提示(VIDEO_PREVIEW_END 开关控,一次进页面只弹一次) + guidePushBanner, // 进 app 满3分钟的 VIP「特享内容」顶部推送横幅 +} + +/// 引导弹窗统一调度管理器(全站唯一) +/// +/// 所有付费引导弹窗都经这里触发,统一「多久/什么条件/弹什么」,并保证同一时刻只弹一个(互斥,防叠弹)。 +/// - [schedule] 定时触发:停留 N 时长后弹(如试看/停留计时)。 +/// - [trigger] 立即触发:事件驱动即弹(如进页面/点返回)。 +/// 具体触发条件与弹窗动作由调用方传入(canShow/onShow),本类只负责调度、去重、互斥、生命周期。 +class TimedPopupManager { + static final TimedPopupManager _instance = TimedPopupManager._(); + factory TimedPopupManager() => _instance; + TimedPopupManager._(); + + /// 进行中的计时任务(按类型去重,同类型再次 schedule 会重启计时) + final Map _timers = {}; + + /// 互斥:同一时刻只显示一个引导弹窗,避免多场景叠弹 + bool _showing = false; + + /// 定时触发:停留 [delay] 后满足条件弹。同 [type] 重复调用会取消上一个、重新计时。 + /// + /// ⚠️ 计时无条件起,[canShow] 只在到点时判一次。别在这里加"起步先判一次不满足就不计时"的优化: + /// 这些条件(分层、免费次数、登录态)本来就是等着这 N 分钟里变的,起步挡掉就再也起不来了 + /// ——切号后分层/次数要等接口回来才对,起步判死会让弹窗永久不弹。 + void schedule({ + required TimedPopupType type, + required Duration delay, + required bool Function() canShow, + required Future Function() onShow, + }) { + cancel(type); + _timers[type] = Timer(delay, () { + _timers.remove(type); + trigger(canShow: canShow, onShow: onShow); // 到点走统一出口;被互斥挡下则本次作废 + }); + } + + /// 立即触发:事件驱动(进页面/返回等)满足条件即弹,也是 [schedule] 到点后的统一出口。 + /// 返回的 Future 在弹窗关闭时完成,调用方可 await 以串起后续逻辑(如试看结束链路)。 + /// ⚠️ [onShow] 的 Future 必须最终完成,否则互斥标记不复位、全站引导弹窗会静默失效。 + Future trigger({ + required bool Function() canShow, + required Future Function() onShow, + }) async { + if (_showing) return; // 已有引导弹窗在显示 + if (!canShow()) return; // 弹前二次校验(定时场景期间状态可能变) + _showing = true; + try { + await onShow(); // 弹窗 show 的 Future 在关闭时 resolve + } finally { + _showing = false; + } + } + + /// 取消指定类型的计时任务(页面退出时调用) + void cancel(TimedPopupType type) => _timers.remove(type)?.cancel(); +} diff --git a/lib/assets_tool/app_colors.dart b/lib/assets_tool/app_colors.dart new file mode 100644 index 0000000..dfb9018 --- /dev/null +++ b/lib/assets_tool/app_colors.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +/// 全局色值 +class AppColors { + /// 无背景的一级字体颜色 #333333 + static const Color mainTextColor33 = Color(0xFF333333); + + /// 提示颜色,一般是输入框 hint #999999 + static const Color tipTextColor99 = Color(0xFF999999); + + /// 主背景色(暗色主题)#0F0F0F + static const Color primaryColor = Color(0xFF0F0F0F); + + /// 链接/高亮色 #3E64EA + static const Color primaryHighColor = Color(0xFF3E64EA); + + /// 主题强调色,按钮/选中态用(本项目为橙色)#F68804 + static const Color actionRed = Color(0xFFF68804); +} diff --git a/lib/assets_tool/images.dart b/lib/assets_tool/images.dart new file mode 100644 index 0000000..ebcab7d --- /dev/null +++ b/lib/assets_tool/images.dart @@ -0,0 +1,25 @@ +/// 图片资源路径扩展:'ic_home.png'.homePath => 'assets/images/home/ic_home.png' +extension ImageLoad on String { + //通用 + String get commonImgPath => 'assets/images/common/$this'; + //首页 + String get homePath => 'assets/images/home/$this'; + //视频 + String get videoPath => 'assets/images/video/$this'; + //acg(漫画/小说) + String get acgImgPath => 'assets/images/acg/$this'; + //社区 + String get communityPath => 'assets/images/community/$this'; + //评论 + String get commentPath => 'assets/images/comment/$this'; + //直播 + String get livePath => 'assets/images/live_images/$this'; + //ai + String get aiPath => 'assets/images/ai/$this'; + //我的 + String get mineImgPath => 'assets/images/mine/$this'; + //启动页 + String get launchPath => 'assets/images/launch/$this'; + //app icon + String get appImgPath => 'assets/images/app_icon/$this'; +} diff --git a/lib/config/address.dart b/lib/config/address.dart new file mode 100755 index 0000000..254f03e --- /dev/null +++ b/lib/config/address.dart @@ -0,0 +1,48 @@ +import '../hj_model/splash/domain_source_model.dart'; + +/// 地址数据:接口路径常量 + 启动选线后下发的各类域名 +class Address { + // ===== 路径常量 ===== + /// 接口路径前缀,拼在 baseHost 后面 + static const apiPrefix = 'api/app'; + + /// 视频上传 + static const uploadVideo = '/vid/uploadDotJson'; + + /// 图片上传 + static const uploadImg = '/vid/uploadStatic'; + + /// 通过支付宝API解析银行卡号发卡行和银行卡类别、获取银行LOGO + static const aliCcdApi = 'https://ccdcapi.alipay.com/validateAndCacheCardInfo.json?_input_charset=utf-8&cardBinCheck=true&cardNo='; + + // ===== 域名 / 线路(启动时赋值)===== + /// 选线后的域名 host + static String? baseHost; + + /// 接口基础路径 = baseHost + apiPrefix + static String? baseApiPath; + + /// cdn地址,后台返回 + static String? cdnAddress; + + /// 有声小说 cdn + static String? audioCdnAddress; + + /// 图片加载地址 + static String? baseImagePath; + + /// 落地页地址 + static String? groundUrl; + + /// cdn 线路列表 + static List cdnAddressLists = []; + + /// 当前生效的 cdn 线路 + static Domain? get currentDomain { + final index = cdnAddressLists.indexWhere((e) => e.url == cdnAddress); + return index < 0 ? null : cdnAddressLists[index]; + } + + // ===== 用户态 ===== + static String? token; +} diff --git a/lib/config/config.dart b/lib/config/config.dart new file mode 100644 index 0000000..06a2370 --- /dev/null +++ b/lib/config/config.dart @@ -0,0 +1,72 @@ +import '../hj_model/home/plate_model.dart'; +import '../hj_model/splash/domain_source_model.dart'; + +/// 全局配置:常量 + 启动时由 /ping/domain 下发并缓存的运营配置 +class Config { + // ===== 应用标识 ===== + static const appName = '黄果短剧'; + static const innerVersion = '1.0.0'; + static const isDebug = false; + + /// 播放失败诊断开关:失败时把现场信息复制到剪贴板,供用户回传排查。 + /// ⚠️ 测试包专用,**发正式包前必须改回 false** + static const playDiagnose = false; + + // ===== 密钥 ===== + /// 加密密钥 + static const encryptKey = '65dc07d1b7915c6b2937432b091837a7'; + + /// 签名密钥(防重放攻击) + static const signKey = 'kaFtkDJRcchRMTI9'; + + // ===== 域名 / 线路 ===== + static String dns = '91porn.zmnchs.com'; // 运维提供,返回配置域名 + + /// 本地兜底线路(无本地缓存线路时用) + static const lineList = isDebug + ? [ + 'https://91pornht.remoces.com', + ] + : [ + 'https://d1s84171319hi1.cloudfront.net', + 'https://d2j97wqe01j5pa.cloudfront.net', + ]; + + // ===== AI 功能价格 ===== + static String aiUndressPrice = ''; // 脱衣价格 + static String aiVideoPrice = ''; // 图生视频价格 + static String aiDrawPrice = ''; // 绘图价格 + static String aiNovelPrice = ''; // 小说价格 + static List? aiTypes; // ai功能类型(含排序) + + // ===== 首页模块数据 ===== + static HomePlateModel? plateModule; // 亚模块数据 + static List? jgArea; // 金刚区 + static List? marquees; // 新版跑马灯数据 + static List bannerJumps = []; + static BannerJump? proxyBanner; + + // ===== 搜索 ===== + static List hotWords = []; // 搜索热门词 + static List searchHints = []; // 搜索提示词 + + // ===== 暗网 ===== + static bool darkWebEnable = true; // 是否开启 + static String? darkWebVipName; + static String? darkWebVipId; + static String? darkWebImg; // 暗网图片 + static String? darkWebIcon; // 暗网图标 + static String? darkWebIconName; // 暗网图标名称 + + // ===== 短剧 ===== + static String? shortDramaCardId; // 短剧卡 id,付费墙拉起会员弹窗时默认选中它 + static bool entryPopupEnabled = true; // 「AI爽剧来袭」引导气泡开关,后台置 false 即永久不出现 + + // ===== 开关 / 展示配置 ===== + static bool isStoreOpen = false; // 商店模块开关 + static bool vipMark = true; // vip角标是否展示 + static bool coinMark = true; // 金币角标是否展示 + static bool freeMark = true; // 免费类角标总开关(如「免费试看」) + static String? signIcon; // 签到 + static String? mineBg; // 我的背景配置 +} diff --git a/lib/extension/datetime_extension.dart b/lib/extension/datetime_extension.dart new file mode 100644 index 0000000..f83984c --- /dev/null +++ b/lib/extension/datetime_extension.dart @@ -0,0 +1,13 @@ +import 'package:intl/intl.dart'; + +extension DateTimeNullableX on DateTime? { + String yyyyMMDDFormat({String gap = "-"}) { + if (this == null) return ""; + return DateFormat("yyyy${gap}MM${gap}dd HH:mm:ss").format(this!); + } + + String ymdSMFormat({String gap = "-"}) { + if (this == null) return ""; + return DateFormat("yyyy${gap}MM${gap}dd HH:mm").format(this!); + } +} diff --git a/lib/extension/double_extension.dart b/lib/extension/double_extension.dart new file mode 100644 index 0000000..ec0c473 --- /dev/null +++ b/lib/extension/double_extension.dart @@ -0,0 +1,5 @@ +/// 可空 double 扩展 +extension DoubleNullableX on double? { + /// 防止字段为空报错 + double em() => this ?? 0.0; +} diff --git a/lib/extension/extensions.dart b/lib/extension/extensions.dart new file mode 100644 index 0000000..b45140c --- /dev/null +++ b/lib/extension/extensions.dart @@ -0,0 +1,8 @@ +/// 通用基础类型扩展统一入口 +export 'datetime_extension.dart'; +export 'double_extension.dart'; +export 'int_extension.dart'; +export 'list_extension.dart'; +export 'map_extension.dart'; +export 'num_extension.dart'; +export 'string_extension.dart'; diff --git a/lib/extension/int_extension.dart b/lib/extension/int_extension.dart new file mode 100644 index 0000000..ea44696 --- /dev/null +++ b/lib/extension/int_extension.dart @@ -0,0 +1,19 @@ +import '../hj_utils/date_time_util.dart'; + +/// 可空 int 扩展 +extension IntNullableX on int? { + /// 人性化数量:1234 / 1.2w / 1.3亿;0、负数、null 一律 '0' + String get countStr { + final v = this ?? 0; + if (v <= 0) return '0'; + if (v < 10000) return '$v'; + if (v < 100000000) return '${(v / 10000).toStringAsFixed(1)}w'; + return '${(v / 100000000).toStringAsFixed(1)}亿'; + } + + /// 同 [countStr],但 0/null 返回 [def](按钮上显示「点赞」而不是「0」) + String countOr(String? def) => (this ?? 0) <= 0 ? (def ?? '0') : countStr; + + /// 秒数 → mm:ss 或 HH:mm:ss + String get hmsStr => DateTimeUtil.formatHMS(this ?? 0); +} diff --git a/lib/extension/list_extension.dart b/lib/extension/list_extension.dart new file mode 100644 index 0000000..9934f41 --- /dev/null +++ b/lib/extension/list_extension.dart @@ -0,0 +1,11 @@ +/// 可空 List 扩展 +extension ListNullableX on List? { + /// 长度,null 视为 0 + int em() => this?.length ?? 0; + + /// 是否为空(null 也视为空) + bool empty() => this == null || this!.isEmpty; + + /// 是否非空 + bool notEmpty() => this != null && this!.isNotEmpty; +} diff --git a/lib/extension/map_extension.dart b/lib/extension/map_extension.dart new file mode 100644 index 0000000..3a10766 --- /dev/null +++ b/lib/extension/map_extension.dart @@ -0,0 +1,5 @@ +/// 可空 Map 扩展 +extension MapNullableX on Map? { + /// 长度,null 视为 0 + int em() => this?.length ?? 0; +} diff --git a/lib/extension/num_extension.dart b/lib/extension/num_extension.dart new file mode 100644 index 0000000..5746177 --- /dev/null +++ b/lib/extension/num_extension.dart @@ -0,0 +1,17 @@ +/// 可空 num 扩展 +extension NumNullableX on num? { + /// 防止字段为空报错 + num em() => this ?? 0; + + /// 数字转金额保留两位小数 + String toAmount() => em().toStringAsFixed(2); +} + +/// 非空 num 扩展 +extension NumX on num { + /// 小数点截取 [fractionDigits] 位(直接截断,不四舍五入) + String fixed(int fractionDigits) { + final fixString = toStringAsFixed(fractionDigits + 1); + return fixString.substring(0, fixString.length - 1); + } +} diff --git a/lib/extension/string_extension.dart b/lib/extension/string_extension.dart new file mode 100644 index 0000000..1bcb38b --- /dev/null +++ b/lib/extension/string_extension.dart @@ -0,0 +1,76 @@ +import 'package:intl/intl.dart'; + +import 'datetime_extension.dart'; + +/// DateFormat 构造要解析 pattern,列表里高频调用,按 pattern 缓存 +final _formatters = {}; + +DateFormat _formatterOf(String pattern) => _formatters.putIfAbsent(pattern, () => DateFormat(pattern)); + +/// 可空字符串扩展 +extension StringNullableX on String? { + DateTime? get _localDateTime { + final value = this?.trim(); + if (value == null || value.isEmpty) return null; + return DateTime.tryParse(value)?.toLocal(); + } + + String _format(String pattern) { + final date = _localDateTime; + return date == null ? '' : _formatterOf(pattern).format(date); + } + + // ============= utc 字符串 → 各种本地格式 ============= + + /// yyyy-MM-dd HH:mm:ss + String utcToYMDHMS() => _format('yyyy-MM-dd HH:mm:ss'); + + /// yyyy-MM-dd HH:mm + String utcToYMDHM({String gap = '-'}) => _format('yyyy${gap}MM${gap}dd HH:mm'); + + /// yyyy-MM-dd + String utcToYMD({String gap = '-'}) => _format('yyyy${gap}MM${gap}dd'); + + /// MM月dd日 + String utcToCnMD() { + final date = _localDateTime; + return date == null ? '' : '${date.month}月${date.day}日'; + } + + /// 相对时间:"刚刚 / N分钟前 / N小时前 / N天前 / yyyy-MM-dd HH:mm" + String utcToAgo({String gap = '-'}) { + final date = _localDateTime; + //解析不出:本来就是空值兜底“近期”,脏数据给空串 + if (date == null) return this?.trim().isNotEmpty == true ? '' : '近期'; + + final diff = DateTime.now().difference(date); + final s = diff.inSeconds; + if (s >= 0 && s < 60) return '刚刚'; + final m = diff.inMinutes; + if (m > 0 && m < 60) return '${m + 1}分钟前'; + final h = diff.inHours; + if (h > 0 && h < 24) return '${h + 1}小时前'; + final d = diff.inDays; + if (d > 0 && d < 3) return '${d + 1}天前'; + return date.ymdSMFormat(gap: gap); + } +} + +/// 非空字符串扩展 +extension StringX on String { + /// 截断到最大长度 + String limit(int max) { + if (max <= 0) return ''; + return length <= max ? this : substring(0, max); + } + + /// 是否含 16 个以上的 0,用于识别全零设备号(如全零 UUID) + bool hasManyZeros() { + const zero = 0x30; //'0' + var count = 0; + for (var i = 0; i < length; i++) { + if (codeUnitAt(i) == zero && ++count >= 16) return true; + } + return false; + } +} diff --git a/lib/hj_model/acg/cartoon_more_list.dart b/lib/hj_model/acg/cartoon_more_list.dart new file mode 100644 index 0000000..b304e39 --- /dev/null +++ b/lib/hj_model/acg/cartoon_more_list.dart @@ -0,0 +1,321 @@ +import 'package:hgdj/hj_model/actress_info.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/text_util.dart'; + +import '../cartoon_media_info.dart'; + +class AllMediaInfo { + List? contents; + int? countBrowse; + int? countCollect; + int? countComment; + int? countDisLike; + int? countLike; + int? countPurchases; + int? countView; + String? createdAt; + int? direction; + int? freeTime; + bool? hasFollow; + String? horizontalCover; + List? allActress; + + String? get coverH { + return (horizontalCover?.isNotEmpty == true) + ? horizontalCover + : verticalCover; + } + + String? get coverV { + return (verticalCover?.isNotEmpty == true) + ? verticalCover + : horizontalCover; + } + + String? id; + int? kind; + String? mId; + VidStatus? mediaStatus; + String? mediaType; + String? moduleName; + int? number; + int? permission; + bool? permissionIconHide; + int? price; + String? sId; + String? sectionName; + int? sectionSort; + int? sellType; + int? sortCode; + int? status; + String? summary; + List? tagDetails; + List? tags; + + String? title; + int? totalEpisode; //总集数 + int? currentEpisode; //当前集数 + + int? updateStatus; //0 默认 1、更新中 2、已完结 + String? updateTime; + String? verticalCover; + + AllMediaInfo({ + this.contents, + this.countBrowse, + this.countCollect, + this.countComment, + this.countDisLike, + this.countLike, + this.countPurchases, + this.countView, + this.allActress, + this.createdAt, + this.direction, + this.freeTime, + this.hasFollow, + this.horizontalCover, + this.id, + this.kind, + this.mId, + this.mediaStatus, + this.mediaType, + this.moduleName, + this.number, + this.permission, + this.permissionIconHide, + this.price, + this.sId, + this.sectionName, + this.sectionSort, + this.sellType, + this.sortCode, + this.status, + this.summary, + this.tagDetails, + this.tags, + this.title, + this.totalEpisode, + this.currentEpisode, + this.updateStatus, + this.updateTime, + this.verticalCover, + }); + + //集数状态描述 + String get episodeNumberStatus { + if (updateStatus == 2) { + return '共$totalEpisode话'; + } else { + return '更新${currentEpisode != 0 ? currentEpisode : totalEpisode}话'; + } + } + + //更新状态 + String get updateDesc { + if (updateStatus == 2) { + return '已完结'; + } else { + return '连载中'; + } + } + + AllMediaInfo.fromJson(Map json) { + contents = json["contents"] == null + ? [] + : List.from(json["contents"]!.map((x) => Content.fromJson(x))); + countBrowse = json["countBrowse"]; + countCollect = json["countCollect"]; + countComment = json["countComment"]; + countDisLike = json["countDisLike"]; + countLike = json["countLike"]; + countPurchases = json["countPurchases"]; + countView = json["countView"]; + createdAt = json["createdAt"]; + direction = json["direction"]; + freeTime = json["freeTime"]; + hasFollow = json["hasFollow"]; + horizontalCover = json["horizontalCover"]; + allActress = json["actresses"] == null + ? [] + : List.from( + json["actresses"]!.map((x) => ActressInfo.fromJson(x))); + id = json["id"]; + kind = json["kind"]; + mId = json["mId"]; + mediaStatus = json["mediaStatus"] == null + ? null + : VidStatus.fromMap(json["mediaStatus"]); + mediaType = json["mediaType"]; + moduleName = json["moduleName"]; + number = json["number"]; + permission = json["permission"]; + permissionIconHide = json["permissionIconHide"]; + price = json["price"]; + sId = json["sId"]; + sectionName = json["sectionName"]; + sectionSort = json["sectionSort"]; + sellType = json["sellType"]; + sortCode = json["sortCode"]; + status = json["status"]; + summary = json["summary"]; + tagDetails = json["tagDetails"] == null + ? [] + : List.from( + json["tagDetails"]!.map((x) => TagsBean.fromMap(x))); + tags = parseStringList(json["tags"]); + title = json["title"]; + totalEpisode = json["totalEpisode"]; + currentEpisode = json['currentEpisode']; + updateStatus = json["updateStatus"]; + updateTime = json["updateTime"]; + verticalCover = json["verticalCover"]; + } +} + +class Content { + String? audioUrl; + int? countBrowse; + int? countCollect; + int? countComment; + int? countDisLike; + int? countLike; + int? countPurchases; + String? createdAt; + int? episodeNumber; + int? height; + String? id; + int? listenPermission; + String? md5; + String? mediaId; + int? mediaSize; + VidStatus? mediaStatus; + String? name; + int? playTime; + int? price; + double? ratio; + String? text; + String? updateTime; + List? urlSet; + String? videoUrl; + int? weight; + + Content({ + this.audioUrl, + this.countBrowse, + this.countCollect, + this.countComment, + this.countDisLike, + this.countLike, + this.countPurchases, + this.createdAt, + this.episodeNumber, + this.height, + this.id, + this.listenPermission, + this.md5, + this.mediaId, + this.mediaSize, + this.mediaStatus, + this.name, + this.playTime, + this.price, + this.ratio, + this.text, + this.updateTime, + this.urlSet, + this.videoUrl, + this.weight, + }); + + Content.fromJson(Map json) { + audioUrl = json["audioUrl"]; + countBrowse = json["countBrowse"]; + countCollect = json["countCollect"]; + countComment = json["countComment"]; + countDisLike = json["countDisLike"]; + countLike = json["countLike"]; + countPurchases = json["countPurchases"]; + createdAt = json["createdAt"]; + episodeNumber = json["episodeNumber"]; + height = json["height"]; + id = json["id"]; + listenPermission = json["listenPermission"]; + md5 = json["md5"]; + mediaId = json["mediaId"]; + mediaSize = json["mediaSize"]; + mediaStatus = json["mediaStatus"] == null + ? null + : VidStatus.fromMap(json["mediaStatus"]); + name = json["name"]; + playTime = json["playTime"]; + price = json["price"]; + ratio = double.tryParse(json['ratio']?.toString() ?? ''); + text = json["text"]; + updateTime = json["updateTime"]; + urlSet = parseStringList(json["urlSet"]); + videoUrl = json["videoUrl"]; + weight = json["weight"]; + } + + Map toJson() => { + "audioUrl": audioUrl, + "countBrowse": countBrowse, + "countCollect": countCollect, + "countComment": countComment, + "countDisLike": countDisLike, + "countLike": countLike, + "countPurchases": countPurchases, + "createdAt": createdAt, + "episodeNumber": episodeNumber, + "height": height, + "id": id, + "listenPermission": listenPermission, + "md5": md5, + "mediaId": mediaId, + "mediaSize": mediaSize, + "mediaStatus": mediaStatus?.toJson(), + "name": name, + "playTime": playTime, + "price": price, + "ratio": ratio, + "text": text, + "updateTime": updateTime, + "urlSet": + urlSet == null ? [] : List.from(urlSet!.map((x) => x)), + "videoUrl": videoUrl, + "weight": weight, + }; +} + +class MediaSearchListModel { + bool? hasNext; + List? list; + List? tagMedia; + String? tagId; + int? total; + + MediaSearchListModel({this.hasNext, this.list, this.total, this.tagId}); + + MediaSearchListModel.fromJson(dynamic json) { + hasNext = json['hasNext']; + list = (json['list'] as List?) + ?.map((v) => CartoonMediaInfo.fromJson(v)) + .toList(); + tagMedia = (json['tagMedia'] as List?) + ?.map((v) => CartoonMediaInfo.fromJson(v)) + .toList(); + tagId = json['tagId']; + total = json['total']; + } + + Map toJson() { + final map = {}; + map['hasNext'] = hasNext; + + map['list'] = list?.map((v) => v.toJson()).toList(); + + map['total'] = total; + return map; + } +} diff --git a/lib/hj_model/acg/comic_chapters_model.dart b/lib/hj_model/acg/comic_chapters_model.dart new file mode 100644 index 0000000..1963f9e --- /dev/null +++ b/lib/hj_model/acg/comic_chapters_model.dart @@ -0,0 +1,71 @@ +//漫画章节 +import 'package:hgdj/config/address.dart' as address; +import 'package:path/path.dart' as path; + +import '../media_content.dart'; + +class ComicChapterInfo { + int? episodeNumber; + bool? hasBuy; + String? id; + int? listenPermission; // 0:会员 1:金币购买 2:免费 + String? mediaId; + String? name; + int? price; + String? createdAt; + String? cover; + String? audioUrl; + + bool inFreeEpisode = false; //是否属于免费集数(前 freeEpisode 集) + + MediaContent? mediaContent; //(另外的接口数据返回,外部手动赋值) + + // 有声小说 + String get getRealAudioUrl { + if (audioUrl == null || audioUrl!.isEmpty) { + return ""; + } + if (!audioUrl!.startsWith("http") && !audioUrl!.startsWith("https")) { + return path.join(address.Address.audioCdnAddress ?? '', audioUrl); + } + return audioUrl!; + } + + ComicChapterInfo({ + this.episodeNumber, + this.hasBuy, + this.id, + this.listenPermission, + this.mediaId, + this.name, + this.price, + this.createdAt, + this.cover, + this.audioUrl, + }); + + ComicChapterInfo.fromJson(Map json) { + episodeNumber = json['episodeNumber']; + hasBuy = json['hasBuy']; + id = json['id']; + listenPermission = json['listenPermission']; + mediaId = json['mediaId']; + name = json['name']; + price = json['price']; + createdAt = json['createdAt']; + cover = json['cover']; + audioUrl = json['audioUrl']; + } + + Map toJson() { + final Map data = {}; + data['episodeNumber'] = episodeNumber; + data['hasBuy'] = hasBuy; + data['id'] = id; + data['listenPermission'] = listenPermission; + data['mediaId'] = mediaId; + data['name'] = name; + data['price'] = price; + return data; + } +} diff --git a/lib/hj_model/actress_info.dart b/lib/hj_model/actress_info.dart new file mode 100644 index 0000000..d467901 --- /dev/null +++ b/lib/hj_model/actress_info.dart @@ -0,0 +1,130 @@ +import 'package:hgdj/hj_utils/text_util.dart'; + +class ActressInfo { + String? age; + String? alias; + String? birthday; + String? bloodType; + String? chineseName; + int? collectCount; + num? commentCount; + String? cupSize; + String? debutTime; + String? englishName; + num? galleryCount; + String? gender; + num? growCount; + String? height; + String? id; + int? likeCount; + String? name; + String? portrait; + String? region; + String? score; + List? seriesCover; + String? summary; + String? threeDimensions; + int? type; // //1、女优 2、网黄 3、博主 4、声优 + int? videoCount; + String? weight; + String? office; + int? picsCount; + int? dislikeCount; + bool? hasLiked; + bool? hasDisliked; + bool? hasCollected; + int? viewCount; + bool? hasSubscribe; + + String get heightThreeDimen { + if (threeDimensions?.isNotEmpty == true && height?.isNotEmpty == true) { + return "身高三围: T$height $threeDimensions"; + } + if (height?.isNotEmpty == true) { + return "身高: T$height"; + } + if (threeDimensions?.isNotEmpty == true) { + return "三围: $threeDimensions"; + } + return ""; + } + + ActressInfo(); + + int get workCount { + return (videoCount ?? 0) + (picsCount ?? 0); + } + + String get actressName { + return name ?? ''; + } + + ActressInfo.fromJson(dynamic json) { + age = json['age']; + alias = json['alias']; + birthday = json['birthday']; + bloodType = json['bloodType']; + chineseName = json['chineseName']; + collectCount = json['collectCount']; + commentCount = json['commentCount']; + cupSize = json['cupSize'].toString(); + debutTime = json['debutTime']; + englishName = json['englishName']; + galleryCount = json['galleryCount']; + gender = json['gender']; + growCount = json['growCount']; + height = json['height']; + id = json['id']; + likeCount = json['likeCount']; + name = json['name']; + portrait = json['portrait']; + region = json['region']; + score = json['score']; + seriesCover = parseStringList(json['seriesCover']); + summary = json['summary']; + threeDimensions = json['threeDimensions']; + type = json['type']; + weight = json['weight']; + videoCount = json['videoCount']; + office = json['office']; + picsCount = json['picsCount']; + dislikeCount = json['dislikeCount']; + hasLiked = json['hasLiked']; + hasDisliked = json['hasDisliked']; + hasCollected = json['hasCollected']; + viewCount = json['viewCount']; + hasSubscribe = json['hasSubscribe']; + } + + Map toJson() { + final map = {}; + map['age'] = age; + map['alias'] = alias; + map['birthday'] = birthday; + map['bloodType'] = bloodType; + map['chineseName'] = chineseName; + map['collectCount'] = collectCount; + map['commentCount'] = commentCount; + map['cupSize'] = cupSize.toString(); + map['debutTime'] = debutTime; + map['englishName'] = englishName; + map['galleryCount'] = galleryCount; + map['gender'] = gender; + map['growCount'] = growCount; + map['height'] = height; + map['id'] = id; + map['likeCount'] = likeCount; + map['name'] = name; + map['portrait'] = portrait; + map['region'] = region; + map['score'] = score; + map['seriesCover'] = seriesCover; + map['summary'] = summary; + map['threeDimensions'] = threeDimensions; + map['type'] = type; + map['videoCount'] = videoCount; + map['weight'] = weight; + map['office'] = office; + return map; + } +} diff --git a/lib/hj_model/actress_model.dart b/lib/hj_model/actress_model.dart new file mode 100644 index 0000000..317eef2 --- /dev/null +++ b/lib/hj_model/actress_model.dart @@ -0,0 +1,22 @@ +import 'package:hgdj/hj_model/actress_info.dart'; + +class ActressModel { + ActressModel({ + this.hasFollow, + this.info, + }); + + ActressModel.fromJson(dynamic json) { + hasFollow = json['hasFollow']; + if (json['info'] != null) { + info = ActressInfo.fromJson(json['info']); + } + if (json['desc'] is List) { + desc = (json['desc'] as List).map((e) => e.toString()).toList(); + } + } + + bool? hasFollow; + ActressInfo? info; + List? desc; +} diff --git a/lib/hj_model/banner/comment_top_banner_model.dart b/lib/hj_model/banner/comment_top_banner_model.dart new file mode 100644 index 0000000..26182fb --- /dev/null +++ b/lib/hj_model/banner/comment_top_banner_model.dart @@ -0,0 +1,38 @@ +/// 评论区置顶 Banner(GET /banner/list?scene=COMMENT_TOP) +class CommentTopBannerModel { + String? id; + String? imageUrl; + String? mediaType; // IMAGE | GIF + String? linkType; // INTERNAL | EXTERNAL | NONE + String? linkValue; + int? sort; + + CommentTopBannerModel({ + this.id, + this.imageUrl, + this.mediaType, + this.linkType, + this.linkValue, + this.sort, + }); + + bool get hasLink { + final t = linkType?.toUpperCase(); + return t == 'INTERNAL' || t == 'EXTERNAL'; + } + + bool get isGif => + mediaType?.toUpperCase() == 'GIF' || (imageUrl?.toLowerCase().contains('.gif') ?? false); + + factory CommentTopBannerModel.fromJson(Map? json) { + json ??= {}; + return CommentTopBannerModel( + id: json['id']?.toString(), + imageUrl: json['imageUrl']?.toString(), + mediaType: json['mediaType']?.toString(), + linkType: json['linkType']?.toString(), + linkValue: json['linkValue']?.toString(), + sort: json['sort'] is int ? json['sort'] as int : int.tryParse('${json['sort'] ?? ''}'), + ); + } +} diff --git a/lib/hj_model/cartoon_media_info.dart b/lib/hj_model/cartoon_media_info.dart new file mode 100644 index 0000000..efef5f2 --- /dev/null +++ b/lib/hj_model/cartoon_media_info.dart @@ -0,0 +1,369 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_model/splash/ads_model.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +import 'acg/comic_chapters_model.dart'; +import 'actress_info.dart'; +import 'media_content.dart'; + +class CartoonMediaInfo { + // —— 基础信息 —— + String? id; //文档id + String? title; //标题 + String? author; //作者 + String? summary; //简介 + String? mediaType; //媒体类型 "video":动漫,"image":漫画 "text":小说 + int? mediaSubType; //子类型 ,暂时只有小说使用 0-默认文本小说 1-有声小说 + int? kind; //种类(1、哩番,2、3D,3、同人动画,4、单行本,5、连载漫画,6、韩漫 ) + int? status; //状态 + VidStatus? mediaStatus; //媒体状态 + String? lsjId; // 老司机媒体资源ID + + // —— 封面 —— + String? horizontalCover; //横版封面 + String? verticalCover; //竖版封面 + + // —— 集数 / 更新 —— + int? totalEpisode; //总集数 + int? currentEpisode; //当前集数 + int? freeEpisode; //免费集数 + bool isCartoonFreeEpisode = + false; //当前播放集是否属于免费集(前 freeEpisode 集):运行时按当前集号计算,命中则不拦播、进度可全程拖 + int? updateStatus; //更新状态 0 默认 1、更新中 2、已完结 + String? updateTime; //文档更新时间 + String? createdAt; //文档创建时间 + + // —— 价格 / 权限 —— + int? price; //价格(整部购买的类型 小说 cos 写真) + int? discountPrice; // 折扣价(后端字段,App 未使用) + int? contentsPrice; //所有子集的价格 + int? permission; // 0:会员 1:金币购买 2:免费 + bool? permissionIconHide; //售卖类型标识隐藏 + + // —— 专题 / 模块 —— + String? mId; //模块ID + String? moduleName; //模块名称 + String? sId; //专题id + String? sectionName; //专题名称 + int? sectionSort; //专题排序 + int? sortCode; //排序 + + // —— 统计 —— + int? countBrowse; //浏览数 + int? countCollect; //收藏数 + int? countDisLike; //不喜欢数 + int? countComment; //评论数 + int? countLike; //喜欢数 + int? countPurchases; //购买数 + int? countView; //展现数 + bool? hasFollow; //是否关注 + + // —— 标签 / 演员 / 内容 —— + List? tagDetails; //标签对象 + List? allActress; // 演员列表 + ActressInfo? actress; // 主演员 + List? contents; // 子集内容列表 + AdsInfoModel? randomAdsInfo; // 随机广告 + + // —— 其他后端字段 —— + int? direction; // 方向(横/竖) + int? freeTime; // 免费试看时长 + bool? isDelete; // 是否已删除 + int? choiceSort; // 后端字段,App 未使用 + int? countPurchasesRate; // 后端字段,App 未使用 + String? updatedAct; // 后端字段,App 未使用 + + // —— 本地 / UI 字段(非后端返回) —— + List? episodeList; // 通过接口获取,外部赋值(改版了,不是同一个接口返回) + int? episodeCurPage; // 当前页码 + int episodePageSize = 40; // 每页集数 + int isUpSort = 0; //0-正序 1-倒序 + bool haxNextEpisode = true; // 是否还有下一页 + int? episodeNumber; //本地添加字段。用于判断,跳转到播放详情的index + bool isSelected = false; // 是否选中 + String? heroTag; //本地字段,用于传递hero动画tag 随机数 + + String? get coverH => + horizontalCover?.isNotEmpty == true ? horizontalCover : verticalCover; + + String? get coverV => + verticalCover?.isNotEmpty == true ? verticalCover : horizontalCover; + + String get unit => mediaType == 'image' ? '话' : '章'; + //更新状态 + String get updateDesc => updateStatus == 1 ? '连载中' : '已完结'; + + String get tagIds => tagDetails?.map((e) => e.id).toList().join(',') ?? ''; + String get tagNames => + tagDetails?.map((e) => e.name).toList().join(',') ?? ''; + + //更新状态 + String get getDetailUpdateString { + if (updateStatus == 2 || updateStatus == 0) { + return '已完结'; + } else { + return '连载中'; + } + } + + Color get getDetailUpdateColor { + if (updateStatus == 2 || updateStatus == 0) { + return Color(0xff989898); + } else { + return AppColors.actionRed; + } + } + + //是否是有声小说 + bool get isAudiobooks => mediaType == 'text' && mediaSubType == 1; + + //集数状态描述 + String get episodeNumberStatus { + if (updateStatus == 1) { + if (currentEpisode == 0) { + return '共$totalEpisode$unit'; + } else { + return '更新$currentEpisode$unit'; + } + } else { + return '共$totalEpisode$unit'; + } + } + + //评论类型,传参数类型 + String get commentType => 'cartoon'; + + /// 该集是否落在前 N 集免费区间内。 + /// 集号缺失兜底为 1(与项目其他处 `episodeNumber ?? 1` 口径一致);服务端真给 0 或负数时判为非免费 + bool isFreeEpisodeNumber(int? episodeNumber) { + final ep = episodeNumber ?? 1; + return ep >= 1 && (freeEpisode ?? 0) >= ep; + } + + /// 转成播放器用的 [VideoModel],[episode] 为当前集 + /// 顺带把 [isCartoonFreeEpisode] 按当前集号刷新一次(有副作用,别当纯转换用) + VideoModel toVideoModel(ComicChapterInfo? episode) { + final model = VideoModel() + ..videoType = 1 + ..id = id + ..totalEpisode = totalEpisode + ..updateStatus = updateStatus + ..mediaType = mediaType + ..mediaInfo = this + ..title = title + ..tags = tagDetails + ..cover = coverH + ..likeCount = countCollect // 视频likecount是收藏数 + ..freeTime = freeTime ?? 0 + ..collectCount = countCollect + ..commentCount = countComment + ..playCount = countBrowse + ..freeArea = permission == 2 + ..coins = price + ..originCoins = price + ..vidStatus = (VidStatus() + ..hasPaid = mediaStatus?.hasPaid == true + ..hasCollected = mediaStatus?.hasCollected + ..hasLiked = mediaStatus?.hasLiked + ..hasDisliked = mediaStatus?.hasDisliked); + + // 动漫免费集:当前集号在前 freeEpisode 集内 → 播放不拦、进度可全程拖(与 video_logic 免费章节判定同一口径) + isCartoonFreeEpisode = isFreeEpisodeNumber(episode?.episodeNumber); + + final list = episodeList ?? []; + if (list.isNotEmpty) { + //在列表里找不到当前集就退回第一集,别让播放信息整块空掉 + final index = list.indexWhere((e) => e.id == episode?.id); + final content = list[index < 0 ? 0 : index].mediaContent; + model + ..subid = content?.id + ..name = content?.name + ..sourceURL = content?.videoUrl + ..playTime = content?.playTime; + } + return model; + } + + //acg子集角标显示逻辑,0:会员 1:金币购买 2:免费。为 false(无任何权限)时才展示角标 + bool get hasPermission { + return (mediaStatus?.hasPaid == true && permission == 1) || + permission == 2 || + (permission == 0 && globalStore.isVIP) || + globalStore.isSuperUp; + } + + CartoonMediaInfo( + {this.contents, + this.choiceSort, + this.countBrowse, + this.countCollect, + this.countComment, + this.countLike, + this.countPurchases, + this.countPurchasesRate, + this.countView, + this.createdAt, + this.direction, + this.freeTime, + this.horizontalCover, + this.id, + this.isDelete, + this.kind, + this.mId, + this.mediaType, + this.moduleName, + this.permission, + this.permissionIconHide, + this.price, + this.sId, + this.discountPrice, + this.sectionName, + this.sectionSort, + this.sortCode, + this.status, + this.summary, + this.tagDetails, + this.title, + this.totalEpisode, + this.currentEpisode, + this.updateStatus, + this.updateTime, + this.updatedAct, + this.verticalCover, + this.author, + this.freeEpisode, + this.contentsPrice, + this.mediaSubType, + this.actress}); + + CartoonMediaInfo.fromJson(dynamic json) { + freeEpisode = json['freeEpisode']; + mediaSubType = json['mediaSubType']; + author = json['author']; + + choiceSort = json['choiceSort']; + totalEpisode = json['totalEpisode']; + currentEpisode = json['currentEpisode']; + countBrowse = json['countBrowse']; + countCollect = json['countCollect']; + countComment = json['countComment']; + countLike = json['countLike']; + countDisLike = json['countDisLike']; + countPurchases = json['countPurchases']; + countPurchasesRate = json['countPurchasesRate']; + discountPrice = json['discountPrice']; + countView = json['countView']; + createdAt = json['createdAt']; + direction = json['direction']; + freeTime = json['freeTime']; + horizontalCover = json['horizontalCover']; + id = json['id']; + isDelete = json['isDelete']; + kind = json['kind']; + mId = json['mId']; + mediaType = json['mediaType']; + moduleName = json['moduleName']; + + permission = json['permission']; + permissionIconHide = json['permissionIconHide']; + price = json['price']; + sId = json['sId']; + sectionName = json['sectionName']; + sectionSort = json['sectionSort']; + + sortCode = json['sortCode']; + status = json['status']; + summary = json['summary']; + lsjId = json['lsjId']; + actress = + json["actress"] == null ? null : ActressInfo.fromJson(json["actress"]); + allActress = json["actresses"] == null + ? [] + : List.from( + json["actresses"]!.map((x) => ActressInfo.fromJson(x))); + hasFollow = json['hasFollow']; + if (json['tagDetails'] != null) { + tagDetails = []; + json['tagDetails'].forEach((v) { + tagDetails?.add(TagsBean.fromMap(v)); + }); + } + if (json['contents'] != null) { + contents = []; + json['contents'].forEach((v) { + contents?.add(MediaContent.fromJson(v)); + }); + } + + if (json['title'] == null) { + title = json['name']; + } else { + title = json['title']; + } + updateStatus = json['updateStatus']; + updateTime = json['updateTime']; + updatedAct = json['updatedAct']; + verticalCover = json['verticalCover']; + mediaStatus = VidStatus.fromMap(json['mediaStatus']); + contentsPrice = json['contentsPrice']; + } + + ///videoModel是否随机广告 + bool isRandomAd() { + return randomAdsInfo != null; + } + + Map toJson() { + final map = {}; + map['mediaSubType'] = mediaSubType; + map['choiceSort'] = choiceSort; + map['countBrowse'] = countBrowse; + map['countCollect'] = countCollect; + map['countComment'] = countComment; + map['countLike'] = countLike; + map['countDisLike'] = countDisLike; + map['countPurchases'] = countPurchases; + map['countPurchasesRate'] = countPurchasesRate; + map['countView'] = countView; + map['createdAt'] = createdAt; + map['direction'] = direction; + map['freeTime'] = freeTime; + map['horizontalCover'] = horizontalCover; + map['id'] = id; + map['isDelete'] = isDelete; + map['kind'] = kind; + map['mId'] = mId; + map['mediaType'] = mediaType; + map['moduleName'] = moduleName; + + map['permission'] = permission; + map['permissionIconHide'] = permissionIconHide; + map['price'] = price; + map['sId'] = sId; + map['sectionName'] = sectionName; + map['sectionSort'] = sectionSort; + + map['sortCode'] = sortCode; + map['status'] = status; + map['summary'] = summary; + map['lsjId'] = lsjId; + if (tagDetails != null) { + map['tagDetails'] = tagDetails?.map((v) => v.toJson()).toList(); + } + if (contents != null) { + map['contents'] = contents?.map((v) => v.toJson()).toList(); + } + map['hasFollow'] = hasFollow; + map['title'] = title; + map['totalEpisode'] = totalEpisode; + map['currentEpisode'] = currentEpisode; + map['updateTime'] = updateTime; + map['updateStatus'] = updateStatus; + map['updatedAct'] = updatedAct; + map['verticalCover'] = verticalCover; + map['mediaStatus'] = mediaStatus; + return map; + } +} diff --git a/lib/hj_model/comment/comment_list_res.dart b/lib/hj_model/comment/comment_list_res.dart new file mode 100644 index 0000000..ae9b4da --- /dev/null +++ b/lib/hj_model/comment/comment_list_res.dart @@ -0,0 +1,38 @@ +import 'package:hgdj/hj_model/comment/comment_model.dart'; + +///评论列表返回信息 +class CommentListRes { + List? list; + int? total; + bool? hasNext = false; + List? quickSearchList; + + CommentListRes.fromJson(Map? json) { + json ??= {}; + list = + (json['list'] as List?)?.map((e) => CommentModel.fromJson(e)).toList(); + total = json['total']; + hasNext = json['hasNext']; + quickSearchList = (json['quickSearchList'] as List?) + ?.map((e) => CommentLink.fromJson(e)) + .toList(); + } +} + +///评论区快捷入口 +class CommentLink { + String? id; + String? title; + String? searchKeyword; + String? link; + int? type; //1:评论置顶 2:评论大家都在搜 + + CommentLink.fromJson(Map? json) { + json ??= {}; + id = json['id']; + title = json['title']; + searchKeyword = json['searchKeyword']; + link = json['link']; + type = json['type']; + } +} diff --git a/lib/hj_model/comment/comment_model.dart b/lib/hj_model/comment/comment_model.dart new file mode 100644 index 0000000..6ef2afb --- /dev/null +++ b/lib/hj_model/comment/comment_model.dart @@ -0,0 +1,65 @@ +import 'package:hgdj/hj_model/comment/reply_model.dart'; + +class CommentModel { + String? id; + + //用户信息 + int? userID; + String? userName; + String? userPortrait; + bool? superUser; + int? vipLevel; + + //评论内容 + String? content; + String? image; + String? createdAt; + bool? isGodComment; //神评论 + bool? official; //官方评论,读取用 isOfficial + + //互动数据 + int? likeCount; + int? commCount; + bool? isLike; + + //超链接评论(点评论跳搜索/外链) + String? searchKeyword; + int? linkType; //1:内链跳转 2:外链跳转 + String? linkStr; + + //子回复,分页在本地维护 + List? replies; + bool? hasMoreReply = false; //还有更多子回复 + int? replyPage = 1; + + bool get isOfficial => official == true; + + //超链接评论隐藏点赞/回复入口 true 隐藏 false 显示 + bool get isHideLikeOrComment => + (linkType == 1 || linkType == 2) && + linkStr?.isNotEmpty == true && + searchKeyword?.isNotEmpty == true; + + CommentModel.fromJson(Map? json) { + json ??= {}; + id = json['id']; + userID = json['userID']; + userName = json['userName']; + userPortrait = json['userPortrait']; + superUser = json['superUser']; + vipLevel = json['vipLevel']; + content = json['content']; + image = json['image']; + createdAt = json['createdAt']; + isGodComment = json['isGodComment']; + official = json['isOfficial']; + likeCount = json['likeCount']; + commCount = json['commCount']; + isLike = json['isLike']; + searchKeyword = json['searchKeyword']; + linkType = json['linkType']; + linkStr = json['linkStr']; + replies = + (json['Info'] as List?)?.map((e) => ReplyModel.fromJson(e)).toList(); + } +} diff --git a/lib/hj_model/comment/reply_model.dart b/lib/hj_model/comment/reply_model.dart new file mode 100644 index 0000000..f5be5de --- /dev/null +++ b/lib/hj_model/comment/reply_model.dart @@ -0,0 +1,36 @@ +class ReplyModel { + String? id; + + //用户信息 + int? userID; + String? userName; + String? userPortrait; + + //被回复人 + int? toUserID; + String? toUserName; + + //回复内容 + String? content; + String? image; + String? createdAt; + + //互动数据 + int? likeCount; + bool? isLike; + + ReplyModel.fromJson(Map? json) { + json ??= {}; + id = json['id']; + userID = json['userID']; + userName = json['userName']; + userPortrait = json['userPortrait']; + toUserID = json['toUserID']; + toUserName = json['toUserName']; + content = json['content']; + image = json['image']; + createdAt = json['createdAt']; + likeCount = json['likeCount']; + isLike = json['isLike']; + } +} diff --git a/lib/hj_model/comment/user_comment_res.dart b/lib/hj_model/comment/user_comment_res.dart new file mode 100644 index 0000000..80a7ab2 --- /dev/null +++ b/lib/hj_model/comment/user_comment_res.dart @@ -0,0 +1,23 @@ +///我的评论列表项 +class UserCommentItem { + String? id; + String? objID; + String? content; + int? likeCount; + bool? isGodComment; + String? vidTitle; + String? vidCover; + String? createdAt; + + UserCommentItem.fromJson(Map? json) { + json ??= {}; + id = json['id']; + objID = json['objID']; + content = json['content']; + likeCount = json['likeCount']; + isGodComment = json['isGodComment']; + vidTitle = json['vidTitle']; + vidCover = json['vidCover']; + createdAt = json['createdAt']; + } +} diff --git a/lib/hj_model/drama/drama_models.dart b/lib/hj_model/drama/drama_models.dart new file mode 100644 index 0000000..73d904f --- /dev/null +++ b/lib/hj_model/drama/drama_models.dart @@ -0,0 +1,191 @@ +import 'package:hgdj/hj_model/drama_media_info.dart'; +import 'package:hgdj/hj_model/media_content.dart'; + +/// 一条短剧观看记录:续播位置 + 列表展示用的剧信息快照。本地记录,不走后端,见 [DramaResumeStore]。 +/// 落库时整条存成 model_data,所以 [mediaId] 必须写进 json—— +/// 基类只回 model_data 一列,读盘时全靠它认领是哪部剧 +class DramaResume { + final String mediaId; //哪部剧,同时是行主键 + final String contentId; //上次看的分集。定位只认它,别再存「第几集」——分页深度靠边翻边找就够了 + final int progressSeconds; //看到第几秒 + + /// 剧信息快照(封面/剧名/集数)。续播定位用不到,是历史记录页拿它画卡片—— + /// 只存 id 的话列表除了 id 什么都渲染不出来 + final DramaMediaInfo? drama; + + /// 本条写入时刻(毫秒)。历史记录页按它倒序,不看 db 的 create_time—— + /// 老数据没这个字段,读出来是 0,排在最后 + final int watchedAt; + + const DramaResume({ + required this.mediaId, + required this.contentId, + required this.progressSeconds, + this.drama, + this.watchedAt = 0, + }); + + DramaResume.fromJson(dynamic json) + : mediaId = json?['mediaId'] ?? '', + contentId = json?['contentId'] ?? '', + progressSeconds = json?['progressSeconds'] ?? 0, + drama = json?['drama'] == null + ? null + : DramaMediaInfo.fromJson(json['drama']), + watchedAt = json?['watchedAt'] ?? 0; + + Map toJson() => { + 'mediaId': mediaId, + 'contentId': contentId, + 'progressSeconds': progressSeconds, + 'drama': drama?.toJson(), + 'watchedAt': watchedAt, + }; +} + +/// 付费墙:文案/价格/余额全部服务端下发,前端不自己拼 +class DramaPaywall { + String? checkoutContextId; //本次付费墙上下文,充值/开卡归因和下单都要带 + String? title; + int? coinBalance; + int? unlockCoin; + String? coinButtonText; + String? cardButtonText; + + DramaPaywall.fromJson(dynamic json) { + json ??= {}; + checkoutContextId = json['checkoutContextId']; + title = json['title']; + coinBalance = json['coinBalance']; + unlockCoin = json['unlockCoin']; + coinButtonText = json['coinButtonText']; + cardButtonText = json['cardButtonText']; + } + + String get titleUI => title?.isNotEmpty == true ? title! : '更多精彩解锁即享'; + + String get coinButtonUI => coinButtonText?.isNotEmpty == true + ? coinButtonText! + : '${unlockCoin ?? 0}金币解锁'; + + String get cardButtonUI => + cardButtonText?.isNotEmpty == true ? cardButtonText! : '开通短剧卡免费看'; +} + +/// AI短剧 Feed 的一条:某部剧 + 该剧第一集。整页走 ListBaseModel +class DramaFeedItem { + DramaMediaInfo? media; + MediaContent? content; + + DramaFeedItem.fromJson(dynamic json) { + json ??= {}; + //缺 media 就不造空模型,上层按 null 过滤掉这条,别让信息流多出一个没剧名没地址的空位 + if (json['media'] != null) media = DramaMediaInfo.fromJson(json['media']); + if (json['content'] != null) + content = MediaContent.fromJson(json['content']); + } +} + +/// 短剧专题(热门短剧 Tab) +class DramaTopic { + String? topicId; + String? name; + String? topicType; + String? systemKey; + int? sort; + int? workCount; + + DramaTopic({ + this.topicId, + this.name, + this.topicType, + this.systemKey, + this.sort, + this.workCount, + }); + + DramaTopic.fromJson(dynamic json) { + json ??= {}; + topicId = json['topicId']?.toString(); + name = json['name']?.toString(); + topicType = json['topicType']?.toString(); + systemKey = json['systemKey']?.toString(); + sort = json['sort'] is int + ? json['sort'] as int + : int.tryParse('${json['sort'] ?? ''}'); + workCount = json['workCount'] is int + ? json['workCount'] as int + : int.tryParse('${json['workCount'] ?? ''}'); + } +} + +/// 短剧搜索结果(`/media/search` kind=4) +class DramaSearchResult { + List list = []; + List tagMediaList = []; + String? tagID; + bool hasNext = false; + + DramaSearchResult.fromJson(dynamic json) { + json ??= {}; + hasNext = json['hasNext'] == true; + tagID = json['tagID']?.toString(); + list = (json['list'] as List?) + ?.map((e) => DramaMediaInfo.fromJson(e)) + .toList() ?? + []; + tagMediaList = (json['tagMediaList'] as List?) + ?.map((e) => DramaMediaInfo.fromJson(e)) + .toList() ?? + []; + } +} + +/// 单集金币解锁的下单结果 +class DramaUnlockResult { + String? orderId; + String? mediaId; + String? contentId; + int? paidCoin; + int? coinBalance; + + DramaUnlockResult.fromJson(Map? json) { + json ??= {}; + orderId = json['orderId']; + mediaId = json['mediaId']; + contentId = json['contentId']; + paidCoin = json['paidCoin']; + coinBalance = json['coinBalance']; + } +} + +/// 短剧单集下载授权结果。权益/上下架/次数校验和扣次都在服务端一次做完, +/// 客户端只负责拿地址开下载,不再自己算次数 +class DramaDownloadAuth { + String? mediaId; + String? contentId; + int? episodeNumber; + String? name; + String? cover; + String? downloadUrl; // H.264 m3u8,可能是相对地址 + String? h265DownloadUrl; // H.265 m3u8,没有时为空串 + int? mediaSize; // 字节 + String? expiresAt; // 本次地址失效时间(签发后 6 小时) + int? remainingDownloadCount; // 扣完之后钱包剩余下载次数,直接用它顶掉本地值 + bool? charged; // false = 幂等重试命中,没有重复扣次 + + DramaDownloadAuth.fromJson(Map? json) { + json ??= {}; + mediaId = json['mediaId']; + contentId = json['contentId']; + episodeNumber = json['episodeNumber']; + name = json['name']; + cover = json['cover']; + downloadUrl = json['downloadUrl']; + h265DownloadUrl = json['h265DownloadUrl']; + mediaSize = json['mediaSize']; + expiresAt = json['expiresAt']; + remainingDownloadCount = json['remainingDownloadCount']; + charged = json['charged'] == true; + } +} diff --git a/lib/hj_model/drama_media_info.dart b/lib/hj_model/drama_media_info.dart new file mode 100644 index 0000000..6bf6af9 --- /dev/null +++ b/lib/hj_model/drama_media_info.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_model/media_content.dart'; +import 'package:hgdj/hj_model/video_model.dart'; + +/// 短剧作品信息(接口 DramaInfo:一部剧 + 一堆分集) +/// 单集播放数据用 [toVideoModel] 转成 VideoModel 交给短视频播放器 +class DramaMediaInfo { + // —— 基础信息 —— + String? id; //短剧id + String? title; //剧名 + String? author; //作者/出品方 + String? summary; //简介 + int? kind; //种类 + int? style; //样式 + int? status; //0 下架 1 上架 + int? number; //序号 + VidStatus? mediaStatus; //媒体状态(已购/已收藏/已点赞) + String? lsjId; // 老司机媒体资源ID + + // —— 封面 —— + String? horizontalCover; //横版封面 + String? verticalCover; //竖版封面 + + // —— 集数 / 更新 —— + int? totalEpisode; //总集数 + int? currentEpisode; //已更新到第几集 + int? freeEpisode; //默认免费集数,仅后台初始化用;能不能播一律看分集的 canPlay + int? updateStatus; //1 连载中 2 已完结 + String? updateTime; //更新时间 + String? createdAt; //创建时间 + + // —— 价格 / 权限 —— + int? price; //整部价格 + int? contentsPrice; //所有分集的价格 + int? permission; // 0:会员/短剧卡 1:金币购买 2:免费 + bool? permissionIconHide; //售卖类型标识隐藏 + + // —— 专题 / 模块 —— + String? mId; //模块ID + String? moduleName; //模块名称 + String? sId; //专题id + String? sectionName; //专题名称 + int? sectionSort; //专题排序 + int? sortCode; //排序 + + // —— 统计 —— + int? countBrowse; //浏览数 + int? countCollect; //收藏数 + int? countDisLike; //不喜欢数 + int? countComment; //评论数 + int? countLike; //喜欢数 + int? countPurchases; //购买数 + int? countView; //展现数 + bool? hasFollow; //是否关注 + + // —— 标签 —— + List? tagDetails; //标签对象 + + // —— 其他后端字段 —— + int? direction; //方向(横/竖) + int? freeTime; //免费试看时长 + + // —— 本地 / UI 字段(非后端返回) —— + List? episodeList; //分集列表,走 /media_content/list 单独拉,外部赋值 + int? episodeCurPage; //当前页码 + //每页集数:必须与选集面板的分段大小(DramaEpisodeSheet._segmentSize)一致, + //这样第 i 页正好是第 i 段,点哪段补哪页,不会出现「点进去先 10 个再变 30 个」 + int episodePageSize = 30; + int isUpSort = 0; //0-正序 1-倒序 + bool haxNextEpisode = true; //是否还有下一页 + bool isSelected = false; //是否选中 + + String? get coverH => + horizontalCover?.isNotEmpty == true ? horizontalCover : verticalCover; + + String? get coverV => + verticalCover?.isNotEmpty == true ? verticalCover : horizontalCover; + + String get unit => '集'; + + //更新状态 + String get updateDesc => updateStatus == 1 ? '连载中' : '已完结'; + + String get tagIds => tagDetails?.map((e) => e.id).toList().join(',') ?? ''; + + String get tagNames => + tagDetails?.map((e) => e.name).toList().join(',') ?? ''; + + Color get updateDescColor => + updateStatus == 1 ? AppColors.actionRed : const Color(0xff989898); + + //集数状态描述 + String get episodeNumberStatus { + if (updateStatus == 1 && (currentEpisode ?? 0) > 0) { + return '更新$currentEpisode$unit'; + } + //没有集数就别出这行——直接插值会渲染成「共null集」 + return (totalEpisode ?? 0) > 0 ? '共$totalEpisode$unit' : ''; + } + + //点赞/评论/分享统一传这个类型 + String get commentType => 'drama'; + + /// 转成播放器用的 [VideoModel],[episode] 为当前集;列表卡片场景传 null + VideoModel toVideoModel(MediaContent? episode) { + return VideoModel() + ..id = id + ..dramaInfo = this + ..dramaEpisode = episode + ..subid = episode?.id + ..episodeNo = episode?.episodeNumber + ..totalEpisode = totalEpisode + ..updateStatus = updateStatus + ..title = title + ..tags = tagDetails + ..name = episode?.name + ..cover = episode?.cover?.isNotEmpty == true ? episode?.cover : coverV + ..sourceURL = episode?.videoUrl + ..h265Url = episode?.h265Url + ..playTime = episode?.playTime + ..likeCount = countLike // 短剧点赞走 thumbsUp,操作台右侧显示的就是这个数 + ..freeTime = freeTime ?? 0 + ..collectCount = countCollect + ..commentCount = countComment + ..playCount = countBrowse + ..freeArea = episode?.isFree == true + ..coins = price + ..originCoins = price + ..vidStatus = (VidStatus() + ..hasPaid = mediaStatus?.hasPaid == true + ..hasCollected = mediaStatus?.hasCollected + ..hasLiked = mediaStatus?.hasLiked + ..hasDisliked = mediaStatus?.hasDisliked); + } + + DramaMediaInfo(); + + DramaMediaInfo.fromJson(dynamic json) { + json ??= {}; + id = json['id']; + title = json['title'] ?? json['name']; + author = json['author']; + summary = json['summary']; + kind = json['kind']; + style = json['style']; + status = json['status']; + number = json['number']; + lsjId = json['lsjId']; + horizontalCover = json['horizontalCover']; + verticalCover = json['verticalCover']; + totalEpisode = json['totalEpisode']; + currentEpisode = json['currentEpisode']; + freeEpisode = json['freeEpisode']; + updateStatus = json['updateStatus']; + updateTime = json['updateTime']; + createdAt = json['createdAt']; + price = json['price']; + contentsPrice = json['contentsPrice']; + permission = json['permission']; + permissionIconHide = json['permissionIconHide']; + mId = json['mId']; + moduleName = json['moduleName']; + sId = json['sId']; + sectionName = json['sectionName']; + sectionSort = json['sectionSort']; + sortCode = json['sortCode']; + countBrowse = json['countBrowse']; + countCollect = json['countCollect']; + countDisLike = json['countDisLike']; + countComment = json['countComment']; + countLike = json['countLike']; + countPurchases = json['countPurchases']; + countView = json['countView']; + hasFollow = json['hasFollow']; + direction = json['direction']; + freeTime = json['freeTime']; + mediaStatus = VidStatus.fromMap(json['mediaStatus']); + tagDetails = + (json['tagDetails'] as List?)?.map((e) => TagsBean.fromMap(e)).toList(); + } + + /// 落本地观看记录用(见 [DramaResume.drama])。只序列化后端字段, + /// episodeList / 分页游标那些是运行期状态,进二级页会重新拉,存了也没意义 + Map toJson() => { + 'id': id, + 'title': title, + 'author': author, + 'summary': summary, + 'kind': kind, + 'style': style, + 'status': status, + 'number': number, + 'lsjId': lsjId, + 'horizontalCover': horizontalCover, + 'verticalCover': verticalCover, + 'totalEpisode': totalEpisode, + 'currentEpisode': currentEpisode, + 'freeEpisode': freeEpisode, + 'updateStatus': updateStatus, + 'updateTime': updateTime, + 'createdAt': createdAt, + 'price': price, + 'contentsPrice': contentsPrice, + 'permission': permission, + 'permissionIconHide': permissionIconHide, + 'mId': mId, + 'moduleName': moduleName, + 'sId': sId, + 'sectionName': sectionName, + 'sectionSort': sectionSort, + 'sortCode': sortCode, + 'countBrowse': countBrowse, + 'countCollect': countCollect, + 'countDisLike': countDisLike, + 'countComment': countComment, + 'countLike': countLike, + 'countPurchases': countPurchases, + 'countView': countView, + 'hasFollow': hasFollow, + 'direction': direction, + 'freeTime': freeTime, + 'mediaStatus': mediaStatus?.toJson(), + 'tagDetails': tagDetails?.map((e) => e.toJson()).toList(), + }; +} diff --git a/lib/hj_model/home/collection_model.dart b/lib/hj_model/home/collection_model.dart new file mode 100644 index 0000000..578b8b4 --- /dev/null +++ b/lib/hj_model/home/collection_model.dart @@ -0,0 +1,48 @@ +/// 合集(专辑)信息,会被 history_util 序列化进本地观看历史 +class CollectionModel { + int? collectCount; + int? commentCount; + String? cover; + String? createdAt; + bool? hasBought; + String? id; + bool? isRecommend; + int? likeCount; + int? mediaCount; + String? name; + int? price; + String? updateTime; + int? viewCount; + + CollectionModel.fromJson(Map json) { + collectCount = json['collectCount']; + commentCount = json['commentCount']; + cover = json['cover']; + createdAt = json['createdAt']; + hasBought = json['hasBought']; + id = json['id']; + isRecommend = json['isRecommend']; + likeCount = json['likeCount']; + mediaCount = json['mediaCount']; + name = json['name']; + price = json['price']; + updateTime = json['updateTime']; + viewCount = json['viewCount']; + } + + Map toJson() => { + 'collectCount': collectCount, + 'commentCount': commentCount, + 'cover': cover, + 'createdAt': createdAt, + 'hasBought': hasBought, + 'id': id, + 'isRecommend': isRecommend, + 'likeCount': likeCount, + 'mediaCount': mediaCount, + 'name': name, + 'price': price, + 'updateTime': updateTime, + 'viewCount': viewCount, + }; +} diff --git a/lib/hj_model/home/module_detail_model.dart b/lib/hj_model/home/module_detail_model.dart new file mode 100644 index 0000000..d3e0961 --- /dev/null +++ b/lib/hj_model/home/module_detail_model.dart @@ -0,0 +1,140 @@ +import '../actress_info.dart'; +import '../cartoon_media_info.dart'; +import '../splash/ads_model.dart'; +import '../video_model.dart'; + +/// 模块详情:一个首页 tab 拉回来的全部内容 +class ModuleDetailModel { + List? allSection; //所有专题 + List? allVideoInfo; //所有视频 + List? allActress; + List? allMediaInfo; + List? chosenVideoInfo; // 精选视频 + bool? hasNext; + + //只有热门随机刷新那条路径会手工造一个,只带 allVideoInfo + ModuleDetailModel({this.allVideoInfo}); + + ModuleDetailModel.fromJson(Map? json) { + json ??= {}; + chosenVideoInfo = _list(json['chosenVideoInfo'], (e) => VideoModel.fromJson(e)); + allSection = _list(json['allSection'], (e) => AllSection.fromJson(e)); + allVideoInfo = _list(json['allVideoInfo'], (e) => VideoModel.fromJson(e)); + allActress = _list(json['allActress'], (e) => ActressInfo.fromJson(e)); + allMediaInfo = _list(json['allMediaInfo'], (e) => CartoonMediaInfo.fromJson(e)); + hasNext = json['hasNext']; + } + + //某个字段下发成非数组时只丢这一项,别让整个模块的解析失败 + static List? _list(dynamic v, T Function(dynamic) fromJson) => v is List ? v.map(fromJson).toList() : null; +} + +/// 专题:既是首页楼层,也被 AdManager 拿来当广告占位插进列表 +class AllSection { + String? sectionID; + String? sectionName; + String? sectionTitle; + String? sectionDesc; + String? sectionCover; + String? desc; + + List? allTags; + List? allVideoInfo; + List? allMediaInfo; + List? allActress; + + int? linkType; + String? linkUrl; + + /// 横版(长视频):101 一大四小 / 102 四宫格 / 103 六宫格 / 104 1.5滑动 / 105 2.5滑动 / 106 横屏列表 / 107 横屏大图 + /// 竖版(短视频、ACG):201 四宫格 / 202 六宫格 / 203 九宫格 / 204 1.5滑动 / 205 2.5滑动 + /// 301 猜你喜欢 + int? showType; + int? type; + int? voteCount; //大胸投票 + int? disVoteCount; //贫胸投票 + int? collCount; + int? viewCounts; + bool? collected; + bool? hasNext; + bool? hot; + + ///videoModel是否随机广告 + AdsInfoModel? randomAdsInfo; + List? adsInfoArr; + + bool get isGuessLike => showType == 301; // 猜你喜欢 + + bool isRandomAd() { + return randomAdsInfo != null; + } + + bool isAdsArr() { + return adsInfoArr != null; + } + + AllSection(); + + AllSection.fromJson(dynamic json) { + allActress = (json['allActress'] as List?)?.map((e) => ActressInfo.fromJson(e)).toList(); + allVideoInfo = (json['allVideoInfo'] as List?)?.map((e) => VideoModel.fromJson(e)).toList(); + allMediaInfo = (json['allMediaInfo'] as List?)?.map((e) => CartoonMediaInfo.fromJson(e)).toList(); + allTags = (json['allTags'] as List?)?.map((e) => TagsBean.fromMap(e)).toList(); + hot = json['hot']; + desc = json['desc']; + sectionDesc = json['sectionDesc']; + collCount = json['collCount']; + viewCounts = json['viewCounts']; + linkType = json['linkType']; + linkUrl = json['linkUrl']; + sectionID = json['sectionID'] ?? json['id']; + sectionName = json['sectionName']; + showType = json['showType']; + type = json['type']; + voteCount = json['voteCount']; + disVoteCount = json['disVoteCount']; + collected = json['collected']; + sectionCover = json['sectionCover']; + sectionTitle = json['sectionTitle']; + hasNext = json['hasNext']; + } +} + +/// 片库筛选用的标签,会随 Keyword 一起回传给接口 +class Tags { + String? coverImg; + String? description; + bool? hasCollected; + String? id; + String? name; + int? playCount; + + Tags.fromJson(Map json) { + coverImg = json['coverImg']; + description = json['description']; + hasCollected = json['hasCollected']; + id = json['id']; + name = json['name']; + playCount = json['playCount']; + } + + Map toJson() => { + 'coverImg': coverImg, + 'description': description, + 'hasCollected': hasCollected, + 'id': id, + 'name': name, + 'playCount': playCount, + }..removeWhere((key, value) => value == null); +} + +/// 福利页的限免专区,只用 id 去拉列表、title 做标题 +class WareDiscountAreaData { + String? id; + String? title; + + WareDiscountAreaData.fromJson(Map json) { + id = json["id"]; + title = json["title"]; + } +} diff --git a/lib/hj_model/home/plate_model.dart b/lib/hj_model/home/plate_model.dart new file mode 100644 index 0000000..b371da8 --- /dev/null +++ b/lib/hj_model/home/plate_model.dart @@ -0,0 +1,139 @@ +import 'package:collection/collection.dart'; +import 'package:hgdj/hj_utils/const.dart'; + +//亚模块模型 +class ModuleData { + String? id; + String? moduleName; + //1首页(原首页视频) 2社区 3暗网 4动漫 5漫画 6小说 7黄油 8图集 9短视频 10私密圈 11AI广场 12短剧 + int? type; + //1 模块普通海角系样式(HJShowType) 2 全专题组合样式(AllSectionShowType) + int? showType; + bool? pureVersion; //true:广告纯净模式 + String? defaultTagId; //社区默认选中 + HaiJiaoStyle? haiJiaoStyle; + + ModuleData({this.moduleName, this.type, this.id, this.showType}); + + ModuleData.fromJson(Map? json) { + json ??= {}; + id = json['id']; + moduleName = json['moduleName']; + type = json['type']; + showType = json['showType']; + pureVersion = json['pureVersion']; + defaultTagId = json['defaultTagId']; + haiJiaoStyle = HaiJiaoStyle.fromJson(json['haiJiaoStyle']); + } + + //只给 ModuleSortManager 存本地排序用,读回来只按 id 跟后端最新数据配对,故不存 haiJiaoStyle 等内容字段 + Map toJson() { + return { + "id": id, + "moduleName": moduleName, + "type": type, + "showType": showType, + 'defaultTagId': defaultTagId, + }; + } + + //动漫/漫画/小说 + bool get isACG => type == 4 || type == 5 || type == 6; + + /// 展示用的排序项:top=true 的置顶,最多 4 个。 + /// 标题、sort 值、随机刷新配置一律从这份派生——分头各排一次,遇到同名/同值 remove 会删错元素,两边就对不上了。 + /// haiJiaoStyle 解析完就不再变,算一次存起来,别每个 getter 各构造一遍 + late final List _sortedRules = () { + final rules = [...?haiJiaoStyle?.sortRules]; + final topIndex = rules.indexWhere((e) => e.top == true); + if (topIndex > 0) rules.insert(0, rules.removeAt(topIndex)); + return rules.length > 4 ? rules.sublist(0, 4) : rules; + }(); + + /// 排序 tab(标题 + sort 值),为空返回 null 让调用方退回默认值。 + /// 标题和 sort 值必须一起产出:拆成两个并行数组时,后端只下发其中一个就会静默错位 + List>? get sortTabs => _sortedRules.isEmpty + ? null + : _sortedRules.map((e) => SortTab(e.name ?? '', e.val ?? -1)).toList(); + + /// 按 sort 值取对应的排序规则(用于判断该排序项是否走随机刷新接口) + SortRuleModel? sortRuleOf(int? val) => + _sortedRules.firstWhereOrNull((e) => e.val == val); +} + +class HaiJiaoStyle { + int? defaultShow; //默认展示 0-一排两个 1-一排一个 + int? sectionStyle; //专题展示样式 0-不展示 1-十六岁专题 2-女优专题 3-网黄专题 4-普通专题 + int? showChosenVideo; //0-不展示精选视频 1-展示精选视频 + int? sortShow; //排序规则展示样式 0-不展示 1-展示 + List? sortRules; //排序规则 + + HaiJiaoStyle.fromJson(Map? json) { + json ??= {}; + defaultShow = json['defaultShow']; + sectionStyle = json['sectionStyle']; + showChosenVideo = json['showChosenVideo']; + sortShow = json['sortShow']; + final rules = json['sortRules']; + if (rules is List) + sortRules = rules.map((e) => SortRuleModel.fromJson(e)).toList(); + } +} + +class SortRuleModel { + int? val; + bool? top; //是否置顶 + String? name; + //刷新模式 DEFAULT / RANDOM_TOP_N,判断随机刷新只认这个字段,不许匹配 name 或硬编码 val + String? refreshMode; + + SortRuleModel.fromJson(Map? json) { + json ??= {}; + val = json['val']; + top = json['top']; + name = json['name']; + refreshMode = json['refreshMode']; + } + + bool get isRandomRefresh => refreshMode == 'RANDOM_TOP_N'; +} + +class HomePlateModel { + //解析一律走 _modules,字段缺失也退化成空列表,所以都是非空的,调用方不用判空 + List homePage = []; //首页-视频 + List deepWeb = []; //暗网 + List community = []; //社区 + List novel = []; //小说 + List pics = []; //图集:后端原始模块,展示要用 picsUi + List game = []; //黄游:后端原始模块,展示要用 gameUi + List dramaPage = []; //短剧专题(与 update-markers.modules 按 id 对齐) + + //图集:固定注入「热门推荐」(type -100 对应 PIC 数据源) 再接后端模块 + List get picsUi => [ + ModuleData(moduleName: '热门推荐', type: -100, showType: 1), + ...pics, + ]; + + //黄游:固定注入「热门推荐」(type -101 对应 SEED_LINK 数据源) 再接后端模块,showType 2 走列表样式 + List get gameUi => [ + ModuleData(moduleName: '热门推荐', type: -101, showType: 2), + ...game, + ]; + + HomePlateModel.fromJson(Map? json) { + json ??= {}; + homePage = _modules(json['homePage']); + deepWeb = _modules(json['deepWeb']); + community = _modules(json['community']); + novel = _modules(json['novel']); + pics = _modules(json['pics']); + game = _modules(json['game']); + dramaPage = _modules(json['dramaPage']); + } + + //字段缺失或类型不对(后端偶发下发非数组)都退化成空列表,别让首页解析整个抛掉 + static List _modules(dynamic list) => + (list is List ? list : const []) + .map((e) => ModuleData.fromJson(e)) + .toList(); +} diff --git a/lib/hj_model/home/recommend_list_model.dart b/lib/hj_model/home/recommend_list_model.dart new file mode 100644 index 0000000..a5310c7 --- /dev/null +++ b/lib/hj_model/home/recommend_list_model.dart @@ -0,0 +1,19 @@ +import '../video_model.dart'; + +///推荐界面信息返回 +class RecommendListRes { + List? vInfo; + bool? hasNext; + + /// 推荐队列版本,仅用于日志排查 + String? queueVersion; + + /// 视频列表字段各接口叫法不一,按 list → videos → vInfos 依次取 + RecommendListRes.fromJson(Map? json) { + json ??= {}; + final list = json['list'] ?? json['videos'] ?? json['vInfos']; + vInfo = (list as List?)?.map((e) => VideoModel.fromJson(e)).toList(); + hasNext = json['hasNext']; + queueVersion = json['queueVersion']?.toString(); + } +} diff --git a/lib/hj_model/home/update_marker_model.dart b/lib/hj_model/home/update_marker_model.dart new file mode 100644 index 0000000..535f356 --- /dev/null +++ b/lib/hj_model/home/update_marker_model.dart @@ -0,0 +1,39 @@ +/// 首页内容更新红点标记(GET /content/update-markers) +class HomeUpdateMarkersResp { + String? homeLatestAt; + String? todayLatestAt; + List? modules; + + HomeUpdateMarkersResp({ + this.homeLatestAt, + this.todayLatestAt, + this.modules, + }); + + factory HomeUpdateMarkersResp.fromJson(Map? json) { + json ??= {}; + return HomeUpdateMarkersResp( + homeLatestAt: json['homeLatestAt']?.toString(), + todayLatestAt: json['todayLatestAt']?.toString(), + modules: (json['modules'] as List?) + ?.whereType() + .map((e) => HomeModuleUpdateMarker.fromJson(Map.from(e))) + .toList(), + ); + } +} + +class HomeModuleUpdateMarker { + String? moduleId; + String? latestAt; + + HomeModuleUpdateMarker({this.moduleId, this.latestAt}); + + factory HomeModuleUpdateMarker.fromJson(Map? json) { + json ??= {}; + return HomeModuleUpdateMarker( + moduleId: json['moduleId']?.toString(), + latestAt: json['latestAt']?.toString(), + ); + } +} diff --git a/lib/hj_model/home/video_library_model.dart b/lib/hj_model/home/video_library_model.dart new file mode 100644 index 0000000..3c25777 --- /dev/null +++ b/lib/hj_model/home/video_library_model.dart @@ -0,0 +1,90 @@ +import '../cartoon_media_info.dart'; +import '../video_model.dart'; +import 'module_detail_model.dart'; + +/// 片库筛选面板的全部可选项(分类 / 排序 / 付费 / 标签 / 时间) +class HomeVideoLibrary { + List? canvas; + List? orderBy; + List? paymentType; + List? vidTags; + List? acgTags; + List? timeType; + + HomeVideoLibrary(); + + HomeVideoLibrary.fromJson(dynamic json) { + canvas = (json['canvas'] as List?)?.map((e) => TimeType.fromJson(e)).toList(); + orderBy = (json['orderBy'] as List?)?.map((e) => TimeType.fromJson(e)).toList(); + paymentType = (json['paymentType'] as List?)?.map((e) => TimeType.fromJson(e)).toList(); + vidTags = (json['vidTags'] as List?)?.map((e) => Tags.fromJson(e)).toList(); + acgTags = (json['acgTags'] as List?)?.map((e) => Tags.fromJson(e)).toList(); + timeType = (json['timeType'] as List?)?.map((e) => TimeType.fromJson(e)).toList(); + } + + //一项都没有就别渲染筛选面板了;timeType 不算,它单独有默认值 + bool get isNotEmpty => + canvas?.isNotEmpty == true || + orderBy?.isNotEmpty == true || + paymentType?.isNotEmpty == true || + acgTags?.isNotEmpty == true || + vidTags?.isNotEmpty == true; +} + +/// 筛选面板里的一个选项,key 传给接口、name 给用户看 +class TimeType { + String? key; + String? name; + + TimeType.fromJson(dynamic json) { + key = json['key']; + name = json['name']; + } + + bool get isACG => name?.contains("动漫") == true || name?.contains("漫画") == true; + + Map toJson() => {'key': key, 'name': name}; +} + +/// 用户当前选中的筛选组合,整个塞进请求参数 +class Keyword { + TimeType? canvas; + TimeType? orderBy; + Tags? tags; + TimeType? paymentType; + TimeType? timeType; + + Keyword(); + + Keyword.fromJson(dynamic json) { + canvas = json['canvas'] != null ? TimeType.fromJson(json['canvas']) : null; + orderBy = json['orderBy'] != null ? TimeType.fromJson(json['orderBy']) : null; + tags = json['tags'] != null ? Tags.fromJson(json['tags']) : null; + paymentType = json['paymentType'] != null ? TimeType.fromJson(json['paymentType']) : null; + timeType = json['timeType'] != null ? TimeType.fromJson(json['timeType']) : null; + } + + //拼给调用方比对「筛选条件有没有变」,不作他用 + String get modelKey => "${canvas?.name}${orderBy?.name}${tags?.name}${paymentType?.name}${timeType?.name}"; + + Map toJson() => { + 'canvas': canvas?.toJson(), + 'orderBy': orderBy?.toJson(), + 'tags': tags?.toJson(), + 'paymentType': paymentType?.toJson(), + 'timeType': timeType?.toJson(), + }..removeWhere((key, value) => value == null); +} + +/// 片库筛选结果:影视走 list,ACG 走 allMediaList +class HomeVideoLibraryResult { + bool? hasNext; + List? list; + List? allMediaList; + + HomeVideoLibraryResult.fromJson(dynamic json) { + hasNext = json['hasNext']; + list = (json['list'] as List?)?.map((e) => VideoModel.fromJson(e)).toList(); + allMediaList = (json['allMediaList'] as List?)?.map((e) => CartoonMediaInfo.fromJson(e)).toList(); + } +} diff --git a/lib/hj_model/home/video_list_model.dart b/lib/hj_model/home/video_list_model.dart new file mode 100644 index 0000000..aef667e --- /dev/null +++ b/lib/hj_model/home/video_list_model.dart @@ -0,0 +1,53 @@ +import 'package:hgdj/hj_model/home/module_detail_model.dart'; +import 'package:hgdj/hj_model/video_model.dart'; + +//注意⚠️ VideoListResp 只用于兼容不同业务服务端返回的视频列表 key 不一致的情况 +//注意⚠️ 请勿添加其他类型的数据list,如需添加请单独另外创建新的类 +class VideoListResp { + List? videos; + bool? hasNext; + + /// 搜索返回的标签视频 + List? tagVidList; + String? tagID; + + VideoListResp.fromJson(Map? json) { + json ??= {}; + hasNext = json['hasNext']; + //各业务的视频列表 key 不一样,逐个匹配(后命中的覆盖先命中的) + for (final key in [ + 'videos', + 'vInfos', + 'searchVideo', + 'list', + 'data', + 'searchTag' + ]) { + if (json[key] is List) { + videos = + (json[key] as List).map((e) => VideoModel.fromJson(e)).toList(); + } + } + tagID = json['tagID'] ?? json['libraryTag']?['id']; + if (json['tagVidList'] is List) { + tagVidList = (json['tagVidList'] as List) + .map((e) => VideoModel.fromJson(e)) + .toList(); + } + } +} + +//注意⚠️ 别并进 ListBaseModel:调用方(SectionAllLogic)靠 hasNext 的 null 区分「后端没下发」和「明确没有更多」, +//而 ListBaseModel 会把 null 压成 false,专题全部页会误判成没有下一页、停在第一页 +class SectionListResp { + List? list; + bool? hasNext; + + SectionListResp.fromJson(Map? json) { + json ??= {}; + hasNext = json['hasNext']; + if (json['list'] is List) { + list = (json['list'] as List).map((e) => AllSection.fromJson(e)).toList(); + } + } +} diff --git a/lib/hj_model/list_base_model.dart b/lib/hj_model/list_base_model.dart new file mode 100644 index 0000000..3398ec1 --- /dev/null +++ b/lib/hj_model/list_base_model.dart @@ -0,0 +1,63 @@ +import 'package:hgdj/hj_model/acg/comic_chapters_model.dart'; +import 'package:hgdj/hj_model/actress_model.dart'; +import 'package:hgdj/hj_model/cartoon_media_info.dart'; +import 'package:hgdj/hj_model/comment/reply_model.dart'; +import 'package:hgdj/hj_model/comment/user_comment_res.dart'; +import 'package:hgdj/hj_model/drama/drama_models.dart'; +import 'package:hgdj/hj_model/drama_media_info.dart'; +import 'package:hgdj/hj_model/home/collection_model.dart'; +import 'package:hgdj/hj_model/media_content.dart'; +import 'package:hgdj/hj_model/message/message_dynamic_list.dart'; +import 'package:hgdj/hj_model/mine/exchange/bill_item_model.dart'; +import 'package:hgdj/hj_model/mine/follow_user_list_model.dart'; +import 'package:hgdj/hj_model/mine/promotion_record.dart'; +import 'package:hgdj/hj_model/video_model.dart'; + +import '../hj_page/ai/models/ai_change_face_video_model.dart'; +import '../hj_page/ai/models/ai_record_model.dart'; +import '../hj_page/ai/models/ai_square_model.dart'; +import '../hj_page/live/live_model.dart'; +import '../hj_page/mine/make_money/withdraw_details_model.dart'; + +/// 分页列表通用返回:{ hasNext, total, list } +/// 元素类型由 [T] 决定,解析器在 [_parsers] 里按 Type 注册——**新增 T 记得补一行,漏了 list 就是空的** +class ListBaseModel { + bool? hasNext; + List? list; + int? total; + + // 用 Type 对象做 key 而非 T.toString():release 混淆下类名字符串会被改写, + // 字符串匹配失败会导致 list 不被赋值 → 静默“没数据” + static final Map _parsers = { + VideoModel: (e) => VideoModel.fromJson(e), + CartoonMediaInfo: (e) => CartoonMediaInfo.fromJson(e), + DramaMediaInfo: (e) => DramaMediaInfo.fromJson(e), + DramaFeedItem: (e) => DramaFeedItem.fromJson(e), + CollectionModel: (e) => CollectionModel.fromJson(e), + ComicChapterInfo: (e) => ComicChapterInfo.fromJson(e), + MediaContent: (e) => MediaContent.fromJson(e), + UserCommentItem: (e) => UserCommentItem.fromJson(e), + Promotion: (e) => Promotion.fromJson(e), + BillItemModel: (e) => BillItemModel.fromJson(e), + AiRecordModel: (e) => AiRecordModel.fromJson(e), + AiChangeFaceVideoMod: (e) => AiChangeFaceVideoMod.fromJson(e), + TagsBean: (e) => TagsBean.fromMap(e), + FollowUserModel: (e) => FollowUserModel.fromJson(e), + ActressModel: (e) => ActressModel.fromJson(e), + MessageDynamicList: (e) => MessageDynamicList.fromJson(e), + ReplyModel: (e) => ReplyModel.fromJson(e), + AISquareItemModel: (e) => AISquareItemModel.fromJson(e), + LiveAnchor: (e) => LiveAnchor.fromJson(e), + IncomeModel: (e) => IncomeModel.fromJson(e), + }; + + ListBaseModel.fromJson(Map? map) { + map ??= {}; + total = map['total']; + hasNext = map['hasNext'] ?? false; + final parse = _parsers[T]; + if (parse == null) return; + final raw = map['list']; + list = raw is List ? raw.map(parse).cast().toList() : []; + } +} diff --git a/lib/hj_model/media_content.dart b/lib/hj_model/media_content.dart new file mode 100644 index 0000000..4bad140 --- /dev/null +++ b/lib/hj_model/media_content.dart @@ -0,0 +1,167 @@ +import 'package:hgdj/config/address.dart' as address; +import 'package:hgdj/hj_model/drama/drama_models.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:path/path.dart' as path; + +/// 作品的「一集」:漫画一话 / 小说一章 / 有声一集 / 短剧一集 共用这一个模型, +/// 所以字段是几种业务的并集,取值时按自己的业务挑(如短剧只关心视频与解锁那几个) +class MediaContent { + // ===== 标识 ===== + String? id; //本集 id + String? mediaId; //所属作品(剧/漫画/小说)的 id + int? episodeNumber; //第几集/第几话,从 1 开始 + String? name; //集名,如「第1集」 + String? cover; //本集封面,短剧多为空、回退用剧封面 + + // ===== 资源 ===== + String? videoUrl; //视频相对路径(m3u8),要拼 baseApiPath 才能播 + String? h265Url; //H.265 源,为空表示这一集没有 265 + String? audioUrl; //有声书音频,见 realAudioUrl + List? urlSet; //多清晰度地址集合,短剧下发空数组 + String? text; //小说正文 + String? md5; + int? playTime; //时长(秒) + int? mediaSize; //文件字节数 + int? height; //视频高 + int? weight; //视频宽(后端字段就叫 weight,不是重量) + double? ratio; //宽高比,0 表示后端没算 + + // ===== 权限 / 付费 ===== + //0会员 1金币购买 2免费。**只用于展示**,能不能播看 canPlay + int? listenPermission; + int? price; //单集金币价,0 为免费 + bool? hasBuy; //本集是否已单独购买过 + bool? isFree; //本集是否免费,别再用整部的 freeEpisode 自己算 + bool? canPlay; //能不能播只认这个字段 + String? accessType; // free 免费 / bought 已购 / card 短剧卡 / coin 未解锁 + DramaPaywall? paywall; //付费墙文案与价格,canPlay=false 时必有,能播时为 null + bool? isActive; //是否上架,下架的集不该出现在选集面板 + + // ===== 试看(feed / list / info 三个接口都下发)===== + bool? previewEnabled; //这一集配没配试看 + int? previewStart; //试看起播秒数,0 = 从头 + int? previewSeconds; //试看时长(秒),从 previewStart 起算,到点停下挂付费墙 + String? previewVideoUrl; //试看片地址,与 videoUrl 同格式 + String? previewH265Url; //试看片的 265 源,为空表示没有 + + // ===== 统计 ===== + int? countBrowse; + int? countCollect; + int? countComment; + int? countDisLike; + int? countLike; + int? countPurchases; + VidStatus? mediaStatus; //本集的已购/已收藏/已点赞状态 + + // ===== 时间 ===== + String? createdAt; + String? updateTime; + + //只用来造空 model 占位(拉详情失败时页面据此走错误态),字段一律走 fromJson 赋值 + MediaContent(); + + MediaContent.fromJson(dynamic json) { + json ??= {}; + id = json['id']; + mediaId = json['mediaId']; + episodeNumber = json['episodeNumber']; + name = json['name']; + cover = json['cover']; + + videoUrl = json['videoUrl']; + h265Url = json['h265Url']; + audioUrl = json['audioUrl']; + //用 is List 判而不是 as List:下发 null 或非数组时硬 cast 会抛,整条分集就解析不出来 + final rawUrlSet = json['urlSet']; + urlSet = + rawUrlSet is List ? rawUrlSet.map((e) => e.toString()).toList() : []; + text = json['text']; + md5 = json['md5']; + playTime = json['playTime']; + mediaSize = json['mediaSize']; + height = json['height']; + weight = json['weight']; + //数字直接转;后端也可能下发字符串("1.5"),那时才走 parse,脏值落 null + final rawRatio = json['ratio']; + ratio = rawRatio is num + ? rawRatio.toDouble() + : double.tryParse(rawRatio?.toString() ?? ''); + + listenPermission = json['listenPermission']; + price = json['price']; + hasBuy = json['hasBuy']; + isFree = json['isFree']; + canPlay = json['canPlay']; + accessType = json['accessType']; + paywall = + json['paywall'] != null ? DramaPaywall.fromJson(json['paywall']) : null; + isActive = json['isActive']; + + previewEnabled = json['previewEnabled']; + previewStart = json['previewStart']; + previewSeconds = json['previewSeconds']; + previewVideoUrl = json['previewVideoUrl']; + previewH265Url = json['previewH265Url']; + + countBrowse = json['countBrowse']; + countCollect = json['countCollect']; + countComment = json['countComment']; + countDisLike = json['countDisLike']; + countLike = json['countLike']; + countPurchases = json['countPurchases']; + mediaStatus = json['mediaStatus'] != null + ? VidStatus.fromMap(json['mediaStatus']) + : null; + + createdAt = json['createdAt']; + updateTime = json['updateTime']; + } + + /// 只给浏览历史落库用(经 CartoonMediaInfo.toJson → HistoryUtil)。 + /// paywall 和 preview* 都是这次请求的临时状态、下次进来要重新拉,故意不存 + Map toJson() { + return { + 'id': id, + 'mediaId': mediaId, + 'episodeNumber': episodeNumber, + 'name': name, + 'cover': cover, + 'videoUrl': videoUrl, + 'h265Url': h265Url, + 'audioUrl': audioUrl, + 'urlSet': urlSet, + 'text': text, + 'md5': md5, + 'playTime': playTime, + 'mediaSize': mediaSize, + 'height': height, + 'weight': weight, + 'ratio': ratio, + 'listenPermission': listenPermission, + 'price': price, + 'hasBuy': hasBuy, + 'isFree': isFree, + 'canPlay': canPlay, + 'accessType': accessType, + 'isActive': isActive, + 'countBrowse': countBrowse, + 'countCollect': countCollect, + 'countComment': countComment, + 'countDisLike': countDisLike, + 'countLike': countLike, + 'countPurchases': countPurchases, + if (mediaStatus != null) 'mediaStatus': mediaStatus?.toJson(), + 'createdAt': createdAt, + 'updateTime': updateTime, + }; + } + + /// 有声书音频完整地址:相对路径要拼音频 CDN,已是 http(s) 的直接用。 + /// 目前有声书播的是 ComicChapterInfo 里的同名实现,这里留着与 audioUrl 字段配套 + String get realAudioUrl { + final url = audioUrl; + if (url?.isNotEmpty != true) return ''; + if (url!.startsWith('http')) return url; + return path.join(address.Address.audioCdnAddress ?? '', url); + } +} diff --git a/lib/hj_model/message/message_dynamic_list.dart b/lib/hj_model/message/message_dynamic_list.dart new file mode 100644 index 0000000..a5c4799 --- /dev/null +++ b/lib/hj_model/message/message_dynamic_list.dart @@ -0,0 +1,75 @@ + +class MessageDynamicList { + MessageDynamicList({ + this.createdAt, + this.id, + this.sendGender, + this.content, + this.sendUid, + this.objId, + this.sendAvatar, + this.vipLevel, + this.msgType, + this.sendName, + this.objName, + }); + + MessageDynamicList.fromJson(Map json) { + createdAt = json['createdAt']; + id = json['id']; + sendGender = json['sendGender']; + content = json['content']; + sendUid = json['sendUid']; + createdAt = json['createdAt']; + objId = json['objId']; + sendAvatar = json['sendAvatar']; + vipLevel = json['vipLevel']; + msgType = json['msgType'] ?? ""; + objName = json['objName']; + isRead = json['isRead']; + superUser = json['superUser']; + awards = List.from((json['awards'] as List?) ?? []); + imgUrl = json['imgUrl']; + vipExpireDateOmitempty = json['vipExpireDateOmitempty']; + objCover = json['objCover']; + sendName = json['sendName']; + likeCount = json['likeCount']; + } + String? id; + int? sendUid; + String? sendName; + String? sendAvatar; + String? sendGender; + int? superUser; + List? awards; + String? msgType; + String? content; + String? imgUrl; + bool? isRead; + String? objId; + String? objName; + String? createdAt; + int? likeCount; + String? vipExpireDateOmitempty; + int? vipLevel; + String? objCover; + + Map toJson() { + final map = {}; + map['createdAt'] = createdAt; + map['id'] = id; + map['sendGender'] = sendGender; + map['content'] = content; + map['sendUid'] = sendUid; + + map['objId'] = objId; + map['sendAvatar'] = sendAvatar; + map['vipLevel'] = vipLevel; + map['msgType'] = msgType; + map['objName'] = objName; + map['isRead'] = isRead; + + map['sendName'] = sendName; + return map; + } +} diff --git a/lib/hj_model/mine/credit_record_model.dart b/lib/hj_model/mine/credit_record_model.dart new file mode 100644 index 0000000..7848ef2 --- /dev/null +++ b/lib/hj_model/mine/credit_record_model.dart @@ -0,0 +1,13 @@ +/// 积分记录 +class CreditRecordModel { + String? desc; + num? integral; + String? createdAt; + + CreditRecordModel.fromJson(Map? json) { + json ??= {}; + desc = json['desc']; + integral = json['integral']; + createdAt = json['createdAt']; + } +} diff --git a/lib/hj_model/mine/exchange/bill_item_model.dart b/lib/hj_model/mine/exchange/bill_item_model.dart new file mode 100644 index 0000000..0c5f124 --- /dev/null +++ b/lib/hj_model/mine/exchange/bill_item_model.dart @@ -0,0 +1,45 @@ +/// 单条账单 +class BillItemModel { + final String? createdAt; + final String? desc; + final String? tranType; + final int? tranTypeInt; + + final int? actualAmount; //实际变动金币 + final int? actualIntegral; //实际变动积分 + final int? integral; + + BillItemModel({ + this.createdAt, + this.desc, + this.tranType, + this.tranTypeInt, + this.actualAmount, + this.actualIntegral, + this.integral, + }); + + /// 数量单位:有积分变动记积分,特定交易类型记次数,其余记金币 + String get unit { + if ((integral ?? 0) != 0) return '积分'; + if (const {103, 104, 109, 110, 111}.contains(tranTypeInt)) return '次'; + return '金币'; + } + + /// 展示用数量:金币和积分只会变动其中一种,同时变动时不展示 + int get realCount { + if (actualAmount == 0) return actualIntegral ?? 0; + if (actualIntegral == 0) return actualAmount ?? 0; + return 0; + } + + factory BillItemModel.fromJson(Map json) => BillItemModel( + createdAt: json["createdAt"], + desc: json["desc"], + tranType: json["tranType"], + tranTypeInt: json["tranTypeInt"], + actualAmount: json["actualAmount"], + actualIntegral: json["actualIntegral"], + integral: json["integral"], + ); +} diff --git a/lib/hj_model/mine/exchange/dc_model.dart b/lib/hj_model/mine/exchange/dc_model.dart new file mode 100644 index 0000000..e1c80fb --- /dev/null +++ b/lib/hj_model/mine/exchange/dc_model.dart @@ -0,0 +1,172 @@ +/// 代充商人 +class PayForModel { + int? imId; //商人在聊天系统中的ID + String? userId; //商人在代充系统中的ID + String? avatar; //头像 + String? nickName; //昵称 + String? welcomeMsg; //商人欢迎语 + List? payInfos; //支持的支付方式 + + PayForModel(); + + PayForModel.fromJson(Map? json) { + json ??= {}; + imId = json['imId']; + userId = json['userId']; + avatar = json['avatar']; + nickName = json['nickName']; + welcomeMsg = json['welcomeMsg']; + payInfos = (json['payInfos'] as List?)?.map((e) => PayInfoModel.fromJson(e)).toList(); + } + + Map toJson() => { + "imId": imId, + "userId": userId, + "avatar": avatar, + "welcomeMsg": welcomeMsg, + "nickName": nickName, + "payInfos": payInfos, + }; + + PayForModel clone() => PayForModel() + ..imId = imId + ..userId = userId + ..avatar = avatar + ..nickName = nickName + ..welcomeMsg = welcomeMsg + ..payInfos = payInfos?.map((e) => e.clone()).toList() ?? []; +} + +/// 一种支付方式 +class PayInfoModel { + int? payMethod; + List? payType; + + PayInfoModel(); + + PayInfoModel.fromJson(Map? json) { + json ??= {}; + payMethod = json['payMethod']; + payType = (json['payType'] as List?)?.cast().toList() ?? []; + } + + Map toJson() => { + "payMethod": payMethod, + "payType": payType, + }; + + PayInfoModel clone() => PayInfoModel() + ..payMethod = payMethod + ..payType = [...?payType]; +} + +/// 代充配置,整体 base64 后透传给代充 H5 +class DCModel { + bool? isReconnect; + + /// 商人列表 为空则不展示代充 + List? traders; + String? url; + String? ordUrl; + String? traderUrl; + + int? chargeMoney; + + ///大额支付 小额支付 + int? limit; + String? userAgent; + + String? wsUrl; + + String? picUrl; + + /// 商品id,客户端添加,非后端返回,代充值给h5的时候使用 + String? productInfo; + + DcUserInfo? userInfo; + + /// 支付渠道,客户端下单时赋值,非后端返回 + String? channel; + + DCModel(); + + DCModel.fromJson(Map? json) { + json ??= {}; + isReconnect = json['isReconnect']; + //只取第一个商人,后续逻辑都按 traders[0] 走 + final trader = (json['traders'] as List?)?.firstOrNull; + if (trader != null) traders = [PayForModel.fromJson(trader)]; + url = json['url']; + wsUrl = json['wsUrl']; + picUrl = json['picUrl']; + ordUrl = json['ordUrl']; + traderUrl = json['traderUrl']; + userInfo = DcUserInfo.fromJson(json['userInfo']); + chargeMoney = json['chargeMoney']; + limit = json['limit']; + userAgent = json['userAgent']; + productInfo = json['productInfo']; + } + + Map toJson() => { + "isReconnect": isReconnect, + "traders": traders, + "url": url, + "ordUrl": ordUrl, + "traderUrl": traderUrl, + "userInfo": userInfo, + "chargeMoney": chargeMoney, + "limit": limit, + "wsUrl": wsUrl, + "picUrl": picUrl, + "userAgent": userAgent, + "productInfo": productInfo, + "channel": channel, + }; + + DCModel clone() => DCModel() + ..isReconnect = isReconnect + ..traders = traders?.map((e) => e.clone()).toList() ?? [] + ..url = url + ..ordUrl = ordUrl + ..traderUrl = traderUrl + ..userInfo = userInfo?.clone() + ..chargeMoney = chargeMoney + ..limit = limit + ..userAgent = userAgent + ..wsUrl = wsUrl + ..picUrl = picUrl + ..productInfo = productInfo + ..channel = channel; +} + +/// 代充里透传给 H5 的用户信息 +class DcUserInfo { + int? uid; + String? gender; + String? name; + String? portrait; + + DcUserInfo(); + + DcUserInfo.fromJson(Map? json) { + json ??= {}; + uid = json['uid']; + if (json['gender'] is String) gender = json['gender']; + name = json['name']; + portrait = json['portrait']; + } + + Map toJson() => { + "uid": uid, + "gender": gender, + "name": name, + "portrait": portrait, + }; + + DcUserInfo clone() => DcUserInfo() + ..uid = uid + ..gender = gender + ..name = name + ..portrait = portrait; +} diff --git a/lib/hj_model/mine/exchange/recharge_list_model.dart b/lib/hj_model/mine/exchange/recharge_list_model.dart new file mode 100644 index 0000000..3aaf725 --- /dev/null +++ b/lib/hj_model/mine/exchange/recharge_list_model.dart @@ -0,0 +1,18 @@ +import 'package:hgdj/hj_model/mine/exchange/recharge_type_list_model.dart'; + +import 'dc_model.dart'; + +/// 可供充值的金币列表 +class RechargeListModel { + DCModel? daichong; + List? list; + + RechargeListModel.fromJson(Map? json) { + json ??= {}; + daichong = DCModel.fromJson(json['daichong']); + //代充数据挂在上级,逐条塞给档位,档位内部才能拼出代充支付方式 + list = (json['list'] as List?) + ?.map((e) => RechargeTypeModel.fromJson(e)..daichong = daichong) + .toList(); + } +} diff --git a/lib/hj_model/mine/exchange/recharge_type_list_model.dart b/lib/hj_model/mine/exchange/recharge_type_list_model.dart new file mode 100644 index 0000000..9936e47 --- /dev/null +++ b/lib/hj_model/mine/exchange/recharge_type_list_model.dart @@ -0,0 +1,60 @@ +import '../../../hj_page/mine/mine_vip/vip_support_model.dart'; +import 'dc_model.dart'; + +/// 代充的 payMethod → (type, 展示名) +const _dcPayTypes = { + 101: ('alipy', '支付寶(人工充值)'), + 102: ('wechat', '微信(人工充值)'), + 103: ('union', '银联(人工充值)'), + 104: ('credit', '信用卡(人工充值)'), + 105: ('huabei', '花呗(人工充值)'), + 106: ('yunSanPay', '云闪付(人工充值)'), + 107: ('qqWallet', 'QQ錢包(人工充值)'), + 108: ('jindongPay', '京东支付(人工充值)'), +}; + +/// 一个充值档位 +class RechargeTypeModel { + String? id; + int? amount; + int? money; + String? couponDesc; + DCModel? daichong; // 从上级数据结构手动赋值过来 + List? rechargeTypeList; + + /// 展示用的支付方式:代充那条要按商人支持的 payMethod 展开成多条 + List get rechargeTypeListUI { + final payList = []; + for (final rchg in (rechargeTypeList ?? [])) { + if (rchg.type != 'daichong') { + payList.add(rchg); + continue; + } + final payInfos = daichong?.traders?.firstOrNull?.payInfos ?? []; + for (final info in payInfos) { + final named = _dcPayTypes[info.payMethod]; + payList.add(RchgType() + ..isOfficial = true + ..channel = rchg.channel + ..incrAmount = rchg.incrAmount + ..incTax = rchg.incTax + ..payMethod = info.payMethod + ..type = named?.$1 + ..typeName = named?.$2); + } + } + return payList; + } + + //money是分单位 需要/100 + int get moneyYuan => ((money ?? 0) / 100).round(); + + RechargeTypeModel.fromJson(Map? json) { + json ??= {}; + id = json['id']; + amount = json['amount']; + money = json['money']; + couponDesc = json['couponDesc']; + rechargeTypeList = (json['rchgType'] as List?)?.map((o) => RchgType.fromJson(o)).toList(); + } +} diff --git a/lib/hj_model/mine/exchange/recharge_url_model.dart b/lib/hj_model/mine/exchange/recharge_url_model.dart new file mode 100644 index 0000000..cf9963e --- /dev/null +++ b/lib/hj_model/mine/exchange/recharge_url_model.dart @@ -0,0 +1,13 @@ +/// 下单返回的支付链接 +class RechargeUrlModel { + String? payUrl; + + /// url-打开外部支付链接;sdk-走 sdk + String? mode; + + RechargeUrlModel.fromJson(Map? json) { + json ??= {}; + payUrl = json['payUrl']; + mode = json['mode']; + } +} diff --git a/lib/hj_model/mine/exchange_record_model.dart b/lib/hj_model/mine/exchange_record_model.dart new file mode 100644 index 0000000..596c531 --- /dev/null +++ b/lib/hj_model/mine/exchange_record_model.dart @@ -0,0 +1,25 @@ +/// 兑换码记录列表 +class ExchangeRecordList { + int? total; + List? data; + + ExchangeRecordList.fromJson(Map? json) { + json ??= {}; + total = json['total']; + data = (json['data'] as List?)?.map((e) => ExchangeRecordModel.fromJson(e)).toList(); + } +} + +/// 单条兑换记录 +class ExchangeRecordModel { + String? code; + String? desc; + String? createdAt; + + ExchangeRecordModel.fromJson(Map? json) { + json ??= {}; + code = json['code']; + desc = json['desc']; + createdAt = json['createdAt']; + } +} diff --git a/lib/hj_model/mine/follow_user_list_model.dart b/lib/hj_model/mine/follow_user_list_model.dart new file mode 100644 index 0000000..192fa53 --- /dev/null +++ b/lib/hj_model/mine/follow_user_list_model.dart @@ -0,0 +1,31 @@ +/// 关注 / 粉丝列表里的用户 +class FollowUserModel { + String? id; + int? uid; + String? objcId; + String? name; + String? portrait; + + int? fans; + int? totalWorks; + int? videoCount; + + bool? hasFollow; + bool? hasCollected; + + FollowUserModel.fromJson(Map? json) { + json ??= {}; + id = json['id']; + uid = json['uid']; + objcId = json['objcId']; + name = json['name']; + portrait = json['portrait']; + + fans = json['fans']; + totalWorks = json['totalWorks']; + videoCount = json['videoCount']; + + hasFollow = json['hasFollow']; + hasCollected = json['hasCollected']; + } +} diff --git a/lib/hj_model/mine/happy/happy_model.dart b/lib/hj_model/mine/happy/happy_model.dart new file mode 100644 index 0000000..f6f4582 --- /dev/null +++ b/lib/hj_model/mine/happy/happy_model.dart @@ -0,0 +1,41 @@ +import 'package:hgdj/hj_model/splash/ads_model.dart'; + +class AdGroup { + final List? items; + final String? title; + AdGroup({this.title, this.items}); +} + +class AdTabConfig { + /// 对应着列表数据 + final AdGroup? shuAds; + final List? bannerAds; + + /// 对应着网格排版 + final AdGroup? hengAds; + AdTabConfig({this.shuAds, this.bannerAds, this.hengAds}); +} + +class HappyModel { + List? shuApp; + List? hengApp; + List? adv; + List? gameApp; + List? ypApp; + List? zbApp; + List? qpApp; + + HappyModel.fromJson(Map? json) { + json ??= {}; + shuApp = _ads(json['shuApp']); + hengApp = _ads(json['hengApp']); + adv = _ads(json['adv']); + gameApp = _ads(json['gameApp']); + ypApp = _ads(json['ypApp']); + zbApp = _ads(json['zbApp']); + qpApp = _ads(json['qpApp']); + } + + static List? _ads(dynamic raw) => + raw is! List ? null : raw.map((e) => AdsInfoModel.fromJson(e)).toList(); +} diff --git a/lib/hj_model/mine/official_list_item_model.dart b/lib/hj_model/mine/official_list_item_model.dart new file mode 100644 index 0000000..3023727 --- /dev/null +++ b/lib/hj_model/mine/official_list_item_model.dart @@ -0,0 +1,17 @@ +/// 官方渠道列表项 +class OfficialListItemModel { + String? officialName; + String? officialDesc; + String? officialImg; + String? officialUrl; + num? position; + + OfficialListItemModel.fromJson(Map? json) { + json ??= {}; + officialName = json['officialName']; + officialDesc = json['officialDesc']; + officialImg = json['officialImg']; + officialUrl = json['officialUrl']; + position = json['position']; + } +} diff --git a/lib/hj_model/mine/promotion_record.dart b/lib/hj_model/mine/promotion_record.dart new file mode 100644 index 0000000..422c7d4 --- /dev/null +++ b/lib/hj_model/mine/promotion_record.dart @@ -0,0 +1,13 @@ +/// 推广记录里的下线用户 +class Promotion { + String? name; + String? portrait; + String? createAt; + + Promotion.fromJson(Map? json) { + json ??= {}; + name = json['name']; + portrait = json['portrait']; + createAt = json['createAt']; + } +} diff --git a/lib/hj_model/mine/task_center_data.dart b/lib/hj_model/mine/task_center_data.dart new file mode 100644 index 0000000..d9e20fd --- /dev/null +++ b/lib/hj_model/mine/task_center_data.dart @@ -0,0 +1,66 @@ +/// 积分兑换商品 +class IntegralExchangeModel { + String? id; + int? type; //5_实物,兑换后要联系客服填地址 + String? img; + int? price; + + IntegralExchangeModel.fromJson(Map? json) { + json ??= {}; + id = json['id']; + type = json['type']; + img = json['img']; + price = json['price']; + } +} + +/// 任务中心:按类型分组的任务列表 +class TaskCenterData { + List? dailyTask; //每日任务 + List? onceTask; //一次性任务 + List? growthTasks; //成长任务 + + TaskCenterData.fromJson(Map? json) { + json ??= {}; + dailyTask = _tasks(json['dailyTask']); + onceTask = _tasks(json['onceTask']); + growthTasks = _tasks(json['growthTasks']); + } + + static List? _tasks(dynamic raw) => (raw as List?)?.map((e) => DailyTask.fromJson(e)).toList(); +} + +/// 单个任务 +class DailyTask { + String? id; + String? title; + String? subTitle; + String? desc; + String? img; + String? link; + int? type; + int? prizesIntegral; //完成可得积分 + int? countdownType; + String? startAt; + String? endAt; + int? status; + + /// 任务所属分组,后端不下发,合并列表时由客户端标记:1_每日 2_一次性 3_成长 + int? doType; + + DailyTask.fromJson(Map? json) { + json ??= {}; + id = json['id']; + title = json['title']; + subTitle = json['subTitle']; + desc = json['desc']; + img = json['img']; + link = json['link']; + type = json['type']; + prizesIntegral = json['prizesIntegral']; + countdownType = json['countdownType']; + startAt = json['startAt']; + endAt = json['endAt']; + status = json['status']; + } +} diff --git a/lib/hj_model/mine/vip_card_analytics_event.dart b/lib/hj_model/mine/vip_card_analytics_event.dart new file mode 100644 index 0000000..88c1cd6 --- /dev/null +++ b/lib/hj_model/mine/vip_card_analytics_event.dart @@ -0,0 +1,45 @@ +/// VIP 卡片统计事件(POST /analytics/events) +class VipCardAnalyticsEvent { + final String eventId; + final String eventName; + final String sessionId; + final String occurredAt; + final String? experimentId; + final String? variant; + final String? productId; + final int? price; // 分;下单相关扩展字段,后端可忽略未知字段 + + VipCardAnalyticsEvent({ + required this.eventId, + required this.eventName, + required this.sessionId, + required this.occurredAt, + this.experimentId, + this.variant, + this.productId, + this.price, + }); + + Map toJson() => { + 'eventId': eventId, + 'eventName': eventName, + 'sessionId': sessionId, + 'occurredAt': occurredAt, + if (experimentId != null) 'experimentId': experimentId, + if (variant != null) 'variant': variant, + if (productId != null) 'productId': productId, + if (price != null) 'price': price, + }; +} + +/// 事件名 +abstract class VipCardAnalyticsEventName { + /// 卡皮页展示(A/B) + static const pageView = 'VIP_CARD_PAGE_VIEW'; + + /// 套餐曝光(选中 / 默认选中) + static const productImpression = 'VIP_PRODUCT_IMPRESSION'; + + /// 无购买直接关闭 + static const closeWithoutPurchase = 'VIP_CARD_CLOSE_WITHOUT_PURCHASE'; +} diff --git a/lib/hj_model/splash/ads_model.dart b/lib/hj_model/splash/ads_model.dart new file mode 100644 index 0000000..43edb14 --- /dev/null +++ b/lib/hj_model/splash/ads_model.dart @@ -0,0 +1,121 @@ +import '../../tools_base/banner/ads_grid_view_widget.dart'; + +/// 广告信息 +class AdsInfoModel { + // ----- 基础信息 ----- + String? id; + String? title; + String? cover; //封面图 + String? href; //跳转地址 + String? newUrl; + + // ----- 官方/来源信息 ----- + String? officialImg; + String? officialName; + String? officialDesc; + String? officialUrl; + String? officialType; + + // ----- 展示位置与排序 ----- + String? positionName; + String? imPosition; //IM 广告位标识(IM_LIST_BANNER 等字符串 key) + /// 广告位置 + /// 0 - 启动页广告 + /// 1 - 首页-推荐显示小广告 + /// 2 - 首页-推荐公告展示 + /// 3 - 消息界面广告 + /// 4 - 首页-热点 banner广告 + int? position; + + /// 同一位置使用此进行排序 + int? sortCode; + + /// 广告样式 + /// full-screen 全屏(启动页、短视频) + /// banner-720300 Banner720300 + /// banner-720150 Banner720150 + /// banner-750700 Banner750700 + /// banner-800450 Banner800450 + /// pop-ups-600800 弹窗600800 + /// pop-ups-512512 弹窗512512 + /// small-cube 小方块 + /// vertical-cube 竖方块 + String? coverImgSize; + + // ----- 业务控制 ----- + int? moduleType; //楼凤广告模块过滤 + int? watchTime; //广告关闭时长 + + AdsInfoModel({this.id, this.cover, this.href, this.title}); + + /// 是否有广告倒计时 + bool get hasAdTime => (watchTime ?? 0) > 0; + + /// 根据 coverImgSize 映射广告布局样式(small-cube 与未知值同走默认) + AdStyle get adStyle => switch (coverImgSize) { + 'vertical-cube' => AdStyle.oneScrollBig, + 'banner-720300' => AdStyle.banner, + _ => AdStyle.twoAutoScroll, + }; + + //同时兼容 Adv(image/name/url) 与 ShuApp(icon/name/url) 字段 + AdsInfoModel.fromJson(dynamic json) { + json ??= {}; + id = json['id']; + title = json['title'] ?? json['name']; + cover = json['cover'] ?? json['image'] ?? json['icon']; + href = json['href'] ?? json['url']; + newUrl = json['newUrl']; + officialImg = json['officialImg']; + officialName = json['officialName']; + officialDesc = json['officialDesc']; + officialUrl = json['officialUrl']; + officialType = json['officialType']; + positionName = json['positionName']; + position = json['position']; + sortCode = json['sortCode']; + coverImgSize = json['coverImgSize']; + moduleType = json['moduleType']; + watchTime = json['watchTime']; + } + + //和 fromJson 同一套解析,别再各写一份 + static AdsInfoModel fromMap(Map? map) => AdsInfoModel.fromJson(map); + + Map toJson() => { + "id": id, + "title": title, + "cover": cover, + "href": href, + "position": position, + "sortCode": sortCode, + "positionName": positionName, + }; +} + +/// 公告信息 +class AnnounceInfoBean { + String? content; + String? cover; + String? href; //图片公告点击时跳转的地址 + int? type; //0:系统公告 1:图片公告 3:活动公告 + String? title; + + static AnnounceInfoBean fromJson(Map? map) { + map ??= {}; + return AnnounceInfoBean() + ..content = map['content'] + ..cover = map['cover'] + ..href = map['href'] + ..type = map['type'] + ..title = map['title']; + } + + Map toJson() => { + "content": content, + "cover": cover, + "href": href, + "type": type, + "title": title, + }; +} diff --git a/lib/hj_model/splash/banner_jump.dart b/lib/hj_model/splash/banner_jump.dart new file mode 100644 index 0000000..9ad97e9 --- /dev/null +++ b/lib/hj_model/splash/banner_jump.dart @@ -0,0 +1,23 @@ +class BannerJumpEntity { + String? id; + int? position; + String? url; + String? title; + String? startAt; + String? banner; + String? endAt; + int? countdownType; + + BannerJumpEntity({this.title, this.id, this.position, this.url, this.startAt, this.banner, this.endAt, this.countdownType}); + + BannerJumpEntity.fromJson(dynamic json) { + id = json['id']; + position = json['position']; + url = json['url']; + title = json['title']; + startAt = json['startAt']; + endAt = json['endAt']; + banner = json['banner']; + countdownType = json['countdownType']; + } +} diff --git a/lib/hj_model/splash/cut_info.dart b/lib/hj_model/splash/cut_info.dart new file mode 100644 index 0000000..4d12fe8 --- /dev/null +++ b/lib/hj_model/splash/cut_info.dart @@ -0,0 +1,24 @@ +/// 粘贴版信息 +class CutInfo { + // pc是邀请码promoteCode + String? pc; + + /// dc是渠道 distinctCode + String? dc; + String? tid; + + static CutInfo fromMap(Map? map) { + map ??= {}; + return CutInfo() + ..pc = map['pc'] + ..dc = map['dc'] + ..tid = map['tid']; + } + + Map toJson() => { + "pc": pc, + "dc": dc, + "tid": tid, + }..removeWhere((key, value) => value == null || (value as String?)?.isEmpty == true); +} + diff --git a/lib/hj_model/splash/domain_source_model.dart b/lib/hj_model/splash/domain_source_model.dart new file mode 100644 index 0000000..4b27e15 --- /dev/null +++ b/lib/hj_model/splash/domain_source_model.dart @@ -0,0 +1,605 @@ +import 'package:hgdj/assets_tool/images.dart'; + +import '../../alert/vip_guide/guide_manager.dart'; +import '../../config/config.dart'; +import '../../hj_page/ai/ai_sub_type/ai_function_logic.dart'; +import '../../hj_page/main_page/provider/msg_provider.dart'; +import '../../hj_page/pre_sale/pre_sale_model.dart'; +import 'ads_model.dart'; + +/// 预售/活动状态 +class AdvanceStatus { + bool? activityPopUp; //预售活动是否首页弹窗 + bool? activityStatus; //是否开启 + bool? balancePayment; //是否有尾款支付 + String? oid; + PrivilegeLimit? privilegeLimit; //预付权益 + + AdvanceStatus.fromJson(dynamic json) { + activityStatus = json['activityStatus']; + balancePayment = json['balancePayment']; + activityPopUp = json['activityPopUp']; + oid = json['oid']; + privilegeLimit = json['privilegeLimit'] != null + ? PrivilegeLimit.fromJson(json['privilegeLimit']) + : null; + } +} + +/// 预付权益限制与剩余次数 +class PrivilegeLimit { + bool? hasLimit; //是否有权益限制 + Remain? remain; + + PrivilegeLimit({this.hasLimit, this.remain}); + + PrivilegeLimit.fromJson(Map json) { + hasLimit = json['hasLimit']; + remain = json['remain'] != null ? Remain.fromJson(json['remain']) : null; + } +} + +/// 今日各项功能剩余次数 +class Remain { + int? todayAiUndressCount; //今日剩余ai脱衣次数 + int? todayCoinVideoCount; //今日剩余金币视频观看次数 + int? todayDownloadCount; //今日剩余下载次数 + + Remain( + {this.todayAiUndressCount, + this.todayCoinVideoCount, + this.todayDownloadCount}); + + Remain.fromJson(Map json) { + todayAiUndressCount = json['todayAiUndressCount']; + todayCoinVideoCount = json['todayCoinVideoCount']; + todayDownloadCount = json['todayDownloadCount']; + } + + void reduceVideoCoinCount() { + todayCoinVideoCount = (todayCoinVideoCount ?? 0) - 1; + } +} + +/// 同一份 bannerJump 下按位置取:key '1' 播放页,'2' 全民代理页 +class BannerJump { + AdditionalProp? additionalProp; + + BannerJump({this.additionalProp}); + + BannerJump.fromJson(Map json, {String key = '1'}) { + additionalProp = + json[key] != null ? AdditionalProp.fromJson(json[key]) : null; + } +} + +class AdditionalProp { + String? id; + String? title; //标题 + String? banner; //BANNER图片 + String? url; //跳转地址 + int? position; //位置 1:视频播放器下方 + String? startAt; //开始时间 + String? endAt; //结束时间 + int? countdownType; + + AdditionalProp( + {this.id, + this.banner, + this.endAt, + this.position, + this.startAt, + this.title, + this.url, + this.countdownType}); + + AdditionalProp.fromJson(Map json) { + id = json['id']; + title = json['title']; + banner = json['banner']; + url = json['url']; + position = json['position']; + startAt = json['startAt']; + endAt = json['endAt']; + countdownType = json['countdownType']; + } + + Map toJson() => { + 'id': id, + 'title': title, + 'banner': banner, + 'url': url, + 'position': position, + 'startAt': startAt, + 'endAt': endAt, + 'countdownType': countdownType, + }; +} + +/// 全局配置:域名、广告、AI价格、活动、搜索等 +class DomainSourceModel { + // ----- 域名 / 线路 / 版本 ----- + List? domain; //接口域名 + List? sourceList; + List? ver; + + // ----- 广告 / 公告 ----- + AdsBean? ads; + + // ----- AI 功能价格 ----- + String? aiUndressPrice; //AI脱衣价格 + String? aiImageToVideoPrice; //图生视频价格 + String? aiTextToImagePrice; //绘图价格 + String? aiTextToNovelPrice; //小说价格 + + // ----- 活动 / 预售 / Banner ----- + AdvanceStatus? advanceStatus; + AdvancePage? advancePage; + BannerJump? bannerJump; //限时Banner活动 + BannerJump? proxyBannerJump; + + // ----- 抽奖 ----- + String? luckyDrawH5; //抽奖H5链接 + String? luckyDrawIcon; //抽奖入口浮动图标 + + // ----- 搜索 ----- + List? hotSearchTerms; //搜索热门词 + List? searchHintWord; //搜索提示词 + + // ----- 暗网 VIP ----- + String? darkWebVipId; //暗网vip卡id + String? darkWebVipName; //暗网vip卡名 + String? shortDramaCardId; //短剧卡id(付费墙默认选中的会员卡) + List? recommendVipIds; + + // ----- 其他 ----- + bool? broadcast; //是否展示直播模块 + bool? storeIsOpen; //原味商店开关 + List? jgArea; //金刚区配置 + + static DomainSourceModel fromJson(Map? map) { + map ??= {}; + final info = DomainSourceModel(); + + // 直接写入全局 Config 的配置项 + Config.vipMark = map['vipMark'] ?? true; + Config.coinMark = map['coinMark'] ?? true; + Config.freeMark = map['freeMark'] ?? true; + Config.signIcon = map['signIcon']; + Config.darkWebEnable = map['darkWebEnable'] ?? true; + Config.darkWebImg = map['darkWebImg']; + Config.darkWebIcon = map['darkWebIcon']; + Config.darkWebIconName = map['darkWebIconName']; + Config.mineBg = map['personalCenterBackground']; + Config.entryPopupEnabled = + map['shortDramaEntryPopupEnabled'] ?? true; //短剧引导气泡,字段缺失当开启 + MineMsgProvider().payTier = + PayTierModel.fromJson(map); //支付状态分层(各展示位图片/卡ID/倒计时) + GuideManager().update(map['paymentGuide']); //付费引导各场景开关(弹窗一律读缓存,不再临时查接口) + if (map['aiSwitchConf'] is List) { + Config.aiTypes = (map['aiSwitchConf'] as List) + .map((e) => AISwitchConf.fromJson(e)) + .toList(); + } + if (map.containsKey('bannerJumpList')) { + Config.bannerJumps = (map['bannerJumpList'] as List?) + ?.map((o) => BannerJumpEntity.fromJson(o)) + .toList() ?? + []; + } + + // 域名 / 线路 / 版本 + if (map.containsKey('domain')) { + info.domain = (map['domain'] as List?)?.map((o) => o.toString()).toList(); + } + if (map.containsKey('sourceList')) { + info.sourceList = (map['sourceList'] as List?) + ?.map((o) => Sourcelist.fromJson(o)) + .toList(); + } + if (map.containsKey('ver')) { + info.ver = (map['ver'] as List?) + ?.map((o) => CheckVersionInfo.fromMap(o)) + .toList(); + } + + // 广告 + if (map.containsKey('ads')) { + info.ads = AdsBean.fromMap(map['ads']); + } + + // AI 价格 + info.aiUndressPrice = map['aiUndressPrice']?.toString(); + info.aiImageToVideoPrice = map['aiImageToVideoPrice']?.toString(); + info.aiTextToImagePrice = map['aiTextToImagePrice']?.toString(); + info.aiTextToNovelPrice = map['aiTextToNovelPrice']?.toString(); + + // 活动 / 预售 / Banner + if (map.containsKey('advancePage')) { + info.advancePage = AdvancePage.fromJson(map['advancePage']); + } + if (map.containsKey('advanceStatus')) { + info.advanceStatus = AdvanceStatus.fromJson(map['advanceStatus']); + } + info.bannerJump = map['bannerJump'] != null + ? BannerJump.fromJson(map['bannerJump']) + : null; + info.proxyBannerJump = map['bannerJump'] != null + ? BannerJump.fromJson(map['bannerJump'], key: '2') + : null; + + // 抽奖 + info.luckyDrawH5 = map['luckyDrawH5']; + info.luckyDrawIcon = map['luckyDrawIcon']; + + // 搜索 + if (map['hotSearchTerms'] is List) { + info.hotSearchTerms = + (map['hotSearchTerms'] as List).map((e) => e.toString()).toList(); + } + if (map['searchHintWord'] is List) { + info.searchHintWord = + (map['searchHintWord'] as List).map((e) => e.toString()).toList(); + } + + // 暗网 VIP + info.darkWebVipId = map['darkWebVipId']; + info.darkWebVipName = map['darkWebVipName']; + info.shortDramaCardId = map['shortDramaCardId']; + if (map['recommendVipIds'] is List) { + info.recommendVipIds = + (map['recommendVipIds'] as List).map((e) => e.toString()).toList(); + } + + // 其他 + info.broadcast = map['broadcast'] ?? false; + info.storeIsOpen = map['storeIsOpen']; + if (map['jgArea'] is List) { + info.jgArea = (map['jgArea'] as List?) + ?.map((e) => JGAreaModel.fromJson(e)) + .toList(); + } + return info; + } +} + +/// 跑马灯公告 +class MarqueeModel { + String? content; //跑马灯内容 + String? url; //跳转连接 + bool? active; //激活状态 + String? id; //公告id + int? type; //跑马灯类型,0:会员中心 + int? position; + String? positionDesc; + + static MarqueeModel fromMap(Map? map) { + map ??= {}; + return MarqueeModel() + ..content = map['content'] + ..url = map['url'] + ..active = map['active'] + ..id = map['id'] + ..type = map['type'] + ..position = map['position'] + ..positionDesc = map['positionDesc']; + } +} + +/// 版本检测信息 +class CheckVersionInfo { + String? description; + bool? forcedUpdate; //是否强制更新 + String? platform; + String? url; //Android 的 apk 下载地址 + String? iosUrl; //iOS 的 App Store 地址,点升级直接跳商店 + String? verName; + int? code; + + static CheckVersionInfo fromMap(Map? map) { + map ??= {}; + return CheckVersionInfo() + ..description = map['description'] + ..forcedUpdate = map['forcedUpdate'] + ..platform = map['platform'] + ..url = map['url'] + ..iosUrl = map['iosUrl'] + ..verName = map['verName'] + ..code = map['Code']; + } +} + +/// 广告聚合:图片广告列表 + 文本公告 +class AdsBean { + List? adsInfoList; + List? announInfo; //文本公告 + + ///多个公告 + List? announList; + + static AdsBean fromMap(Map? map) { + map ??= {}; + return AdsBean() + ..announInfo = (map['announInfo'] as List?) + ?.map((o) => AnnounceInfoBean.fromJson(o)) + .toList() + ..announList = (map['announList'] as List?) + ?.map((o) => AnnounceInfoBean.fromJson(o)) + .toList() + ..adsInfoList = (map['adsInfoList'] as List?) + ?.map((o) => AdsInfoModel.fromMap(o)) + .toList(); + } + + Map toJson() => { + "announInfo": announInfo, + }; +} + +/// 金刚区单项 +class JGAreaModel { + String? id; + String? name; + int? type; + String? img; + String? desc; + int? linkType; + String? linkUrl; + String? mid; + + JGAreaModel( + {this.id, + this.name, + this.img, + this.desc, + this.linkType, + this.linkUrl, + this.type, + this.mid}); + + factory JGAreaModel.fromJson(Map json) => JGAreaModel( + id: json["id"], + name: json["name"], + img: json["img"], + desc: json["desc"], + linkType: json["link_type"], + linkUrl: json["link_url"], + type: json["type"], + mid: json["mid"], + ); + + Map toJson() => { + "id": id, + "name": name, + "img": img, + "desc": desc, + "link_type": linkType, + "link_url": linkUrl, + 'mid': mid, + }; +} + +/// 线路源 +class Sourcelist { + String? id; + List? domain; + bool? isActive; + String? type; + + Sourcelist({this.id, this.domain, this.isActive, this.type}); + + factory Sourcelist.fromJson(Map json) => Sourcelist( + id: json["id"], + domain: + List.from(json["domain"].map((x) => Domain.fromJson(x))), + isActive: json["isActive"], + type: json["type"], + ); + + Map toJson() => { + "id": id, + "domain": List.from(domain!.map((x) => x.toJson())), + "isActive": isActive, + "type": type, + }; +} + +/// 域名节点(带权重) +class Domain { + int? weight; + String? url; + String? desc; + int? status; + + Domain({this.weight, this.url, this.desc, this.status}); + + factory Domain.fromJson(Map json) => Domain( + weight: json["weight"], + url: json["url"], + desc: json["desc"], + status: json["status"], + ); + + Map toJson() => { + "weight": weight, + "url": url, + "desc": desc, + "status": status, + }; +} + +//AI 类型配置表:type → 名称/功能枚举/图标(存图标名,读取时拼 .aiPath) +class _AiTypeConf { + final String name; + final AiType type; + final String img; + const _AiTypeConf(this.name, this.type, this.img); +} + +const _aiTypeConfMap = { + 1: _AiTypeConf('AI脱衣', AiType.autoStrip, 'ai_address.png'), + 2: _AiTypeConf('视频换脸', AiType.videoChangeFace, 'ai_video_face.png'), + 3: _AiTypeConf('图片换脸', AiType.imageChangeFace, 'ai_pic_face.png'), + 4: _AiTypeConf('图生视频', AiType.imageToVideo, 'ai_itv.png'), + 5: _AiTypeConf('AI绘画', AiType.aiPaint, 'ai_draw.png'), + 6: _AiTypeConf('AI小说', AiType.aiNovel, 'ai_novel_enter.png'), + 7: _AiTypeConf('AI女友', AiType.aiMate, 'ai_mate_enter.png'), +}; + +/// AI 功能开关配置 +class AISwitchConf { + int? type; //AI类型 1:AI脱衣 2:AI视频换脸 3:AI图片换脸 4:图生视频 5:文生图 6:AI小说 + int? sort; //排序,越小越靠前 + bool? isOpen; //是否开启 + String? aiTypeName; + String? img; //图标(选中/未选中同一张,渲染时统一染白) + AiType? aiType; + + AISwitchConf.fromJson(Map json) { + type = json['type']; + sort = json['sort']; + isOpen = json['isOpen']; + final conf = _aiTypeConfMap[type]; + if (conf != null) { + aiTypeName = conf.name; + aiType = conf.type; + img = conf.img.aiPath; + } + } +} + +/// 限时 Banner 跳转配置 +class BannerJumpEntity { + String? id; + int? position; + String? url; + String? title; + String? startAt; + String? banner; + String? endAt; + int? countdownType; + + BannerJumpEntity( + {this.title, + this.id, + this.position, + this.url, + this.startAt, + this.banner, + this.endAt, + this.countdownType}); + + BannerJumpEntity.fromJson(dynamic json) { + id = json['id']; + position = json['position']; + url = json['url']; + title = json['title']; + startAt = json['startAt']; + endAt = json['endAt']; + banner = json['banner']; + countdownType = json['countdownType']; + } +} + +/// 支付状态分层(ping/domain 的 paymentStatusPopup 字段),用于付费流程的用户分层 +enum PayTier { + newUnpay('new_unpay'), //新用户:注册≤1天且从未付费 → 推新人200卡(新人价+首购引导+紧迫感) + under7DayUnpay('under_7_day_unpay'), //未付费·注册1~7天 → 推300卡(限时优惠+紧迫感) + over7DayUnpay('over_7_day_unpay'), //未付费·注册>7天 → 推300卡(永久卡唤醒需求+信任背书) + over7DayNeedUpgrade( + 'over_7_day_need_upgrade'), //低级会员需续费:注册>7天且已付费、未达最高等级(<5) → 推500至尊卡 + normal('normal'), //正常用户:无特殊弹窗 + unregistered('unregistered'); //未注册(未登录) + + final String value; + const PayTier(this.value); + + static PayTier? from(String? value) { + if (value == null || value.isEmpty) return null; + for (final e in PayTier.values) { + if (e.value == value) return e; + } + return null; + } +} + +/// 支付状态弹窗配置(收敛 ping/domain 支付分层相关字段) +class PayTierModel { + PayTier? status; //用户分层 + PayTierConfig? config; //各展示位配置(图片/卡ID/倒计时) + + PayTierModel({this.status, this.config}); + + PayTierModel.fromJson(Map? json) { + json ??= {}; + status = PayTier.from(json['paymentStatusPopup']); + // /ping/domain 配置在 paymentStatusPopupConfig 下;/ping/domain/refresh 平铺在外层,回退用 json 本身 + config = PayTierConfig.fromJson(json['paymentStatusPopupConfig'] ?? json); + } +} + +/// 支付状态弹窗各展示位配置(meTab/playPage 为图片链接;vipCard 卡ID;lastDiscountTime 优惠截止时间) +/// 全站新人卡/分层优惠倒计时的唯一数据源,各展示位一律读这里,不再另开接口取时间 +class PayTierConfig { + String? homePage; //首页弹窗图片链接 + String? homePageFlot; //首页浮窗图片链接 + String? playPage; //播放页图片链接 + String? meTab; //me tab 图片链接 + String? vipCard; //点击跳转的会员卡 ID + int? lastDiscountTime; //优惠截止 Unix 时间戳(秒),早于当前则不展示倒计时 + + PayTierConfig( + {this.homePage, + this.homePageFlot, + this.playPage, + this.meTab, + this.vipCard, + this.lastDiscountTime}); + + PayTierConfig.fromJson(Map? json) { + json ??= {}; + homePage = json['homepage']; + homePageFlot = json['homepageFlot']; + playPage = json['playPage']; + meTab = json['meTab']; + vipCard = json['vipCard']; + lastDiscountTime = _parseEpochSec(json['lastDiscountTime']); + } + + static const _minValidEpochSec = + 946684800; //2000-01-01,用于挡掉后端零值时间 0001-01-01T00:00:00Z + + //兼容 Unix 秒(int) / 数字字符串 / ISO 字符串(带 Z 或 +08:00 时区);零值哨兵一律归 null,避免负 epoch 流入业务 + static int? _parseEpochSec(dynamic v) { + if (v == null) return null; + int? sec; + if (v is num) { + sec = v.toInt(); + } else if (v is String) { + final s = v.trim(); + if (s.isEmpty) return null; + sec = int.tryParse(s) ?? + (DateTime.tryParse(s)?.millisecondsSinceEpoch ?? 0) ~/ 1000; + } + return (sec != null && sec >= _minValidEpochSec) ? sec : null; + } + + /// 优惠剩余秒数(截止时刻 - 当前),≤0 表示已过期/不展示倒计时 + int get discountRemainSec { + final end = lastDiscountTime ?? 0; + if (end <= 0) return 0; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final r = end - now; + return r > 0 ? r : 0; + } + + bool get hasDiscountCountdown => discountRemainSec > 0; + + String get discountHour => + (discountRemainSec ~/ 3600).toString().padLeft(2, '0'); + + String get discountMin => + ((discountRemainSec ~/ 60) % 60).toString().padLeft(2, '0'); + + String get discountSec => (discountRemainSec % 60).toString().padLeft(2, '0'); +} diff --git a/lib/hj_model/splash/watch_count_model.dart b/lib/hj_model/splash/watch_count_model.dart new file mode 100644 index 0000000..1152141 --- /dev/null +++ b/lib/hj_model/splash/watch_count_model.dart @@ -0,0 +1,21 @@ +class WatchCount { + int? watchCount; // 剩余免费观看次数 + int? total; // 免费观看总次数(后端下发,用于"剩余 x/y 次"展示) + bool? isCan; // 金币视频不再免费次数内 + + //⚠️注意: coinWatchCount: 活动权益金币视频免费观看次数, 需要手动赋值, + int coinWatchCount = -1; //短视频业务专用, 因为短视频业务提前预加载数据,需要copy对象,保存当前对应的count值 + + static WatchCount fromJson(Map? map) { + map ??= {}; + return WatchCount() + ..watchCount = map['watchCount'] + ..total = map['total'] + ..isCan = map['isCan']; + } + + //只复制展示用的次数;coinWatchCount 按注释由调用方自行赋值 + WatchCount copy() => WatchCount() + ..watchCount = watchCount + ..total = total; +} diff --git a/lib/hj_model/user/user_income_info_model.dart b/lib/hj_model/user/user_income_info_model.dart new file mode 100644 index 0000000..0413d59 --- /dev/null +++ b/lib/hj_model/user/user_income_info_model.dart @@ -0,0 +1,47 @@ +/// 推广收益概览 +class UserIncomeModel { + String? totalAmount; // 可提现收益 + String? totalIncomeAmount; // 累计收益总额 + String? totalPayUserCount; // 累计付费用户 + String? totalInviteUserCount; // 累计推广用户 + String? todayInviteUserCount; // 今日推广用户 + String? monthInviteUserCount; // 当月推广用户 + String? monthIncomeAmount; // 当月推广收益金币 + String? todayIncomeAmount; // 今日推广收益金币 + + UserIncomeModel.fromJson(Map? json) { + json ??= {}; + totalAmount = json['totalAmount']; + totalIncomeAmount = json['totalIncomeAmount']; + totalPayUserCount = json['totalPayUserCount']; + totalInviteUserCount = json['totalInviteUserCount']; + todayInviteUserCount = json['todayInviteUserCount']; + monthInviteUserCount = json['monthInviteUserCount']; + monthIncomeAmount = json['monthIncomeAmount']; + todayIncomeAmount = json['todayIncomeAmount']; + } +} + +/// 推广收益列表(分页) +class InviteIncomeModel { + bool? hasNext; + List? items; + + InviteIncomeModel.fromJson(Map? json) { + json ??= {}; + hasNext = json['hasNext']; + items = (json['list'] as List?)?.map((e) => InviteItem.fromJson(e)).toList(); + } +} + +/// 单条推广记录 +class InviteItem { + String? userName; // 被邀请人昵称 + int? incomeAmount; // 该用户带来的收益金币 + + InviteItem.fromJson(Map? json) { + json ??= {}; + userName = json['userName']; + incomeAmount = json['incomeAmount']; + } +} diff --git a/lib/hj_model/user/user_info_model.dart b/lib/hj_model/user/user_info_model.dart new file mode 100644 index 0000000..1c3f2ca --- /dev/null +++ b/lib/hj_model/user/user_info_model.dart @@ -0,0 +1,139 @@ +import 'package:hgdj/assets_tool/images.dart'; + +/// 用户信息 +class UserInfoModel { + // —— 基础资料 —— + int? uid; // 用户ID + String? name; // 昵称 + String? portrait; // 头像 + String? gender; // 性别 + String? summary; // 个性签名 + String? mobile; // 手机号 + int? urrPortraitStatus; // 头像审核状态 + + // —— 账号 / 登录 —— + String? token; // 登录 token + String? devID; // 设备 ID + String? createdAt; // 注册时间 + String? districtCode; // 渠道码 + + // —— 社交统计 —— + int? fans; // 粉丝数 + int? likeCount; // 获赞数 + bool? isFollow; // 是否已关注 + + // —— 会员 / VIP —— + bool? isVip; // 是否会员 + int? vipLevel; // 会员等级 1-影片会员 2-ACG会员 3-超级会员(一卡通) + String? vipName; // 会员名称 + String? vipExpireDate; // 会员到期时间 + bool? isUpgrade; // 是否可升级 + + // —— 推广 / 邀请 —— + String? promoteURL; // 推广链接 + String? promotionCode; // 我的邀请码 + String? inviterCode; // 邀请人邀请码 + + // —— 金币 / 观影券 / 权益 —— + bool allGoldVideoFree = false; // 是否全场金币视频免费 + String? goldVideoFreeExpire; // 金币视频免费权益到期时间 + List? goldVideoCoupon; // 观影券列表 + String? broadcastExpire; // 直播观看权益有效期 + String? dramaExpire; // 短剧卡到期时间,晚于当前时间就是有短剧权益 + int? payVidDiscount; // 付费视频折扣 + + // —— 创作 / 等级 —— + int? vidUploadCount; // 视频上传数 + int? coverUploadCount; // 封面上传数 + int? level; // 当前等级 + + // —— 状态 / 权限 —— + int? adverAbTestShowType; // 广告AB -1-首次进app免广告 0-全部展示 其他N-注册后N分钟内免广告(分层后固定不变) + + // ============ 计算属性 ============ + + /// 观影券总数量 + int get couponCount => + (goldVideoCoupon ?? []).fold(0, (sum, e) => sum + (e.count ?? 0)); + + /// 可解锁该金币视频的观影券面额:券面额需 >= 视频金币,都不够就退回第一张 + int? couponGold(int? originCoins) { + final coupons = goldVideoCoupon ?? []; + for (final coupon in coupons) { + if ((coupon.gold ?? 0) >= (originCoins ?? 0)) return coupon.gold ?? 0; + } + return coupons.isEmpty ? null : coupons.first.gold; + } + + /// 作品总数(视频上传 + 封面上传) + int get totalWorkCount => (vidUploadCount ?? 0) + (coverUploadCount ?? 0); + + /// 会员等级对应的角标图片;等级 1~4,0 级(含推广会员)无角标 + String get vipImageName { + final level = vipLevel ?? 0; + if (level < 1) return ""; + return "vip_${level.clamp(1, 4)}.webp".mineImgPath; + } + + /// 是否充值会员 + bool get isRechargeVIP => (isVip ?? false) && (vipLevel ?? 0) > 0; + + // ============ 序列化 ============ + + UserInfoModel.fromJson(Map? map) { + map ??= {}; + uid = map['uid']; + name = map['name']; + portrait = map['portrait']; + gender = map['gender']; + summary = map['summary']; + mobile = map['mobile']; + urrPortraitStatus = map['urrPortraitStatus']; + + token = map['token']; + devID = map['devID']; + createdAt = map['createdAt']; + districtCode = map['districtCode']; + + fans = map['fans']; + likeCount = map['likeCount']; + isFollow = map['isFollow']; + + isVip = map['isVip']; + vipLevel = map['vipLevel']; + vipName = map['vipName']; + vipExpireDate = map['vipExpireDate']; + isUpgrade = map['isUpgrade']; + + promoteURL = map['promoteURL']; + promotionCode = map['promotionCode']; + inviterCode = map['inviterCode']; + + allGoldVideoFree = map['allGoldVideoFree'] ?? false; + goldVideoFreeExpire = map['goldVideoFreeExpire']; + goldVideoCoupon = (map['goldVideoCoupon'] as List?) + ?.map((o) => MyCoupon.fromJson(o)) + .toList(); + broadcastExpire = map['broadcastExpire']; + dramaExpire = map['dramaExpire']; + payVidDiscount = map['payVidDiscount']; + + vidUploadCount = map['vidUploadCount']; + coverUploadCount = map['coverUploadCount']; + level = map['level']; + + adverAbTestShowType = map['adverAbTestShowType']; + } +} + +/// 观影券 +class MyCoupon { + int? gold; // 券面额(金币) + int? count; // 数量 + + MyCoupon.fromJson(Map? map) { + map ??= {}; + gold = map['gold']; + count = map['count']; + } +} diff --git a/lib/hj_model/user/wallet_model.dart b/lib/hj_model/user/wallet_model.dart new file mode 100644 index 0000000..46affda --- /dev/null +++ b/lib/hj_model/user/wallet_model.dart @@ -0,0 +1,35 @@ +/// 钱包信息,来自 /mine/wallet +class WalletModel { + // ===== 收益 ===== + int? income; //收益金币的整数部分 + double? incomePot; //收益金币的零头,和 income 一起算总收益 + num? vidIncome; //视频收益 + + // ===== 余额 ===== + int? amount; //充值金币余额,购买后会被写回 + int? integral; //积分 + + // ===== 剩余次数 ===== + int? downloadCount; //视频下载次数 + int? aiUndressFreeTimes; //AI 脱衣免费次数 + int? todayAiFreeTimes; //今日 AI 免费次数 + + //字段顺序与上方声明一致,便于比对 + WalletModel.fromJson(Map? json) { + json ??= {}; + income = json['income']; + //后端可能下发 double / int / 数字字符串,统一按字符串解析。 + //解析不了、或拿到 NaN/Infinity 都留 null 当没零头——balance 的 `~/` 吃到它们会抛 UnsupportedError + final pot = double.tryParse('${json['incomePot'] ?? 0}'); + incomePot = pot != null && pot.isFinite ? pot : null; + vidIncome = json['vidIncome']; + amount = json['amount']; + integral = json['integral']; + downloadCount = json['downloadCount']; + aiUndressFreeTimes = json['aiUndressFreeTimes']; + todayAiFreeTimes = json['todayAiFreeTimes']; + } + + //收益总额:先放大 100 倍算再取整,避免 double 直接相加的精度误差 + int get balance => ((income ?? 0) * 100.0 + (incomePot ?? 0) * 100) ~/ 100; +} diff --git a/lib/hj_model/video_model.dart b/lib/hj_model/video_model.dart new file mode 100644 index 0000000..b2e1868 --- /dev/null +++ b/lib/hj_model/video_model.dart @@ -0,0 +1,669 @@ +import 'package:hgdj/hj_model/splash/ads_model.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; + +import '../config/address.dart'; +import '../tools_base/global_store/store.dart'; +import 'cartoon_media_info.dart'; +import 'drama_media_info.dart'; +import 'media_content.dart'; + +/// 通用视频/帖子/图集/动漫/广告等多业务复合模型 +/// 后端用同一个 model 承载多种业务,按 [newsType]、[mediaType]、[videoType] 区分具体形态 +class VideoModel { + // ===== 标识 / 类型 ===== + String? id; // 视频/帖子ID + String? vid; // 视频 vid + String? subid; // 动漫集数id + String? lsjId; // 老司机媒体资源ID + int? uid; // 发布者UID + String? newsType; // 帖子类型 SP/CSP/COVER_SP/COVER/AD_SP/AD_COVER + int? type; // 类型 + int? videoType; // 0视频, 1 动漫 + String? mediaType; // 媒体子类型:'image' 表示漫画(单位为"话"),其他视为视频/动漫(单位为"集") + int? showType; // 0-所有的人都可以看 1-奇数可看 2-偶数可看 + //0 未审核 1通过 2审核失败 3视为免费 5、已下架 + int? status; + String? reason; //审核拒绝理由 + + // ===== 内容 / 展示 ===== + String? title; // 标题 + String? name; // 动漫番号 + String? desc; // 描述 + String? content; // 正文内容 + String? richText; //图文html + String? linkStr; // 富文本 + String? cover; // 封面 + String? coverThumb; // 封面缩略图 + List? seriesCover; // 多封面列表 + List? tags; // 标签列表 + String? via; // 来源渠道 + String? createdAt; // 创建时间 + String? reviewAt; // 审核通过时间 + bool? isTopping; // 是否置顶 + bool? chosen; // 置精 + Publisher? publisher; // 发布者 + CommentBean? comment; // 热评 + + // ===== 播放源 ===== + String? sourceURL; // 源视频地址(H.264,兜底) + String? h265Url; // H.265 播放地址(可空/空串表示无 265 资源) + String? chineseLink; // 中文字幕 + String? codelessLink; // 无码链接 马赛克 + String? previewURL; // 预览片段地址 + int playContentStyle = 1; // 1:源视频,2:中文字幕,3:破解版(无码) + int? playTime; // 时长(秒) + double? ratio; //视频宽高比,没返回或者0,强制设置16/9 + String? resolution; // 分辨率 + int? size; // 文件大小 + int? downloadAllow; // 0表示不允许下载 1表示VIP 2表示免费 + + // ===== 计价 / 权限 ===== + int? coins; // 折扣价格 + int? originCoins; // 原价 + int? permission; //动漫权限 0:会员 1:金币购买 2:免费 + ///是否免费观看 + bool? freeArea; + int? freeTime; // 免费试看时长(秒) + int? previewStart; //预览时间起始 + + /// 是否展示「免费试看」角标(需同时满足 ping 的 freeMark 总开关) + bool? showFreeTrialBadge; + + /// 剩余试看次数 + int? freeTrialRemaining; + + /// 当前视频是否可用免费试看 + bool? canUseFreeTrial; + VidStatus? vidStatus; // 当前用户对该视频的状态 + + // ===== 统计 ===== + int? hot; // 热度 + int? likeCount; // 点赞数 + int? collectCount; // 收藏数 + int? commentCount; // 评论数 + int? playCount; // 播放数 + int? pageViewCount; // 浏览数 + int? videoCount; // 视频数 + int? purchaseCount; // 购买数 + + // ===== 种子 ===== + String? seedLink; // 种子链接 + String? seedLinkUrl; // 种子下载链接 + int? seedSize; //种子大小 + int? seedPlayTime; // 种子时长(秒) + + // ===== 动漫 / 漫画 ===== + CartoonMediaInfo? mediaInfo; // 动漫信息 + int? totalEpisode; // 动漫集数 + int? updateStatus; //更新状态 0 默认(已完结) 1、更新中 2、已完结 + + // ===== 短剧 ===== + // 只由 DramaMediaInfo.toVideoModel 组装,不参与 fromJson——短剧走自己的接口,不从视频列表里解析 + DramaMediaInfo? dramaInfo; // 所属短剧(剧名/总集数/分集列表) + MediaContent? dramaEpisode; // 当前分集(权限/付费墙/播放地址以它为准) + int? episodeNo; // 当前是第几集 + + // ===== 广告 ===== + AdsInfoModel? randomAdsInfo; // 随机广告 + List? adsInfoArr; // 广告列表 + + /// 广告链接 + String? linkUrl; + + // ===== 客户端本地态(不参与接口解析)===== + // 短视频业务用到,二级短视频列表数据没有获取权限,需要通过详情接口获取,边播放边刷新数据。 + VideoModel? detailVModel; // 短视频二级列表详情(无权限,边播边刷) + bool isLoadingDetail = false; // 详情是否加载中 + String? localPath; // 下载到本地的路径 + String? loadProgress; // 下载进度(百分比字符串) + String? isLoaderRunning; // 1 下载中 0 暂停 + bool isSelected = false; // 列表编辑态是否勾选 + String? videoTypeId; // 视频分类 id + String? videoTypeName; // 视频分类名 + + VideoModel({this.id}); + + VideoModel.fromJson(Map? map) { + map ??= {}; + showType = map['showType']; + richText = map['richText']; + previewStart = map['previewStart']; + // hot 后端可能下发 int / double / 字符串 + final hotVal = map['hot']; + hot = hotVal is num ? hotVal.toInt() : int.tryParse('$hotVal'); + if (map['permission'] is int) { + permission = map['permission']; + } + chineseLink = map['chineseLink']; + codelessLink = map["codelessLink"]; + chosen = map['chosen']; + uid = map["uid"]; + isTopping = map['isTopping']; + seedPlayTime = map['seedPlayTime']; + seedLinkUrl = map['seedLinkUrl']; + + seedLink = map['seedLink']; + seedSize = map["seedSize"]; + subid = map["subid"]; + content = map["content"]; + coins = map['coins']; + purchaseCount = map['purchaseCount']; + originCoins = map['originCoins']; + commentCount = map['commentCount']; + cover = map['cover']; + desc = map['desc']; + coverThumb = map['coverThumb']; + createdAt = map['createdAt']; + reviewAt = map['reviewAt']; + freeTime = map['freeTime']; + id = map['id']; + likeCount = map['likeCount']; + collectCount = map['collectCount']; + playCount = map['playCount']; + pageViewCount = map['pageViewCount']; + videoCount = map['videoCount'] ?? 0; + playTime = map['playTime']; + publisher = Publisher.fromMap(map['publisher']); + // 宽高比没返回或为 0 时兜底 16/9 + final r = double.tryParse('${map['ratio'] ?? ''}') ?? 0; + ratio = r == 0 ? 16 / 9 : r; + resolution = map['resolution']; + size = map['size']; + type = map['type']; + sourceURL = map['sourceURL']; + h265Url = map['h265Url']; + previewURL = map['previewURL']; + status = map['status']; + freeArea = map['freeArea']; + showFreeTrialBadge = map['showFreeTrialBadge']; + freeTrialRemaining = map['freeTrialRemaining']; + canUseFreeTrial = map['canUseFreeTrial']; + name = map['name']; + // String 元素生成空 TagsBean,连同空名标签一起被 where 过滤掉 + tags = (map['tags'] as List?) + ?.map((o) => o is String ? TagsBean() : TagsBean.fromMap(o)) + .where((e) => e.mergeName?.isNotEmpty == true) //过滤空字符串/空名标签 + .toList(); + title = map['title']; + via = map['via']; + comment = + map.containsKey("comment") ? CommentBean.fromMap(map['comment']) : null; + vidStatus = VidStatus.fromMap(map['vidStatus']); + if (map['seriesCover'] is List) { + seriesCover = + (map['seriesCover'] as List).map((e) => e.toString()).toList(); + } + newsType = map['newsType']; + reason = map['reason']; + linkUrl = map['linkUrl']; + linkStr = map['linkStr']; + totalEpisode = map['totalEpisode']; + updateStatus = map['updateStatus']; + episodeNo = map['episodeNo'] ?? map['episodeNumber']; + mediaType = map['mediaType']; + vid = map['vid']; + videoType = map['videoType']; + downloadAllow = map['downloadAllow']; + videoTypeId = map['videoTypeId']; + videoTypeName = map['videoTypeName']; + lsjId = map['lsjId']; + // 刻意不解析 mediaInfo:video_detail_bottom_menu 用 `mediaInfo != null` 判定「漫画 / 长视频」, + // 一旦后端下发该字段就会把长视频误判成漫画(收藏走到 deleteBookshelf 分支)。 + // 它只由客户端在 ACG 流程里显式赋值(见 video_logic 与 CartoonMediaInfo.toVideoModel),故两边都不参与序列化 + } + + /// 与 [VideoModel.fromJson] 严格对称:key 集合一致、嵌套对象也展开成 Map, + /// 存进本地(浏览历史 / 下载缓存记录)再读回来不丢字段。分组顺序与上方字段声明一致,便于比对。 + /// 嵌套的那几个必须自己调 toJson()——只放对象时 jsonEncode 那条路能递归转, + /// 但 fromJson(toJson()) 这种内存直接往返会拿对象去喂 fromMap,抛 not a subtype + /// 两边都不参与序列化的:客户端本地态(playContentStyle / localPath / loadProgress / + /// isLoaderRunning / isSelected / detailVModel,由运行时查询回填) + /// 以及 mediaInfo(原因见 fromJson 末尾注释)。 + Map toJson() => { + // 标识 / 类型 + 'id': id, + 'vid': vid, + 'subid': subid, + 'lsjId': lsjId, + 'uid': uid, + 'newsType': newsType, + 'type': type, + 'videoType': videoType, + 'mediaType': mediaType, + 'showType': showType, + 'status': status, + 'reason': reason, + // 内容 / 展示 + 'title': title, + 'name': name, + 'desc': desc, + 'content': content, + 'richText': richText, + 'linkStr': linkStr, + 'cover': cover, + 'coverThumb': coverThumb, + 'seriesCover': seriesCover, + 'tags': tags?.map((e) => e.toJson()).toList(), + 'via': via, + 'createdAt': createdAt, + 'reviewAt': reviewAt, + 'isTopping': isTopping, + 'chosen': chosen, + 'publisher': publisher?.toJson(), + 'comment': comment?.toJson(), + // 播放源 + 'sourceURL': sourceURL, + 'h265Url': h265Url, + 'chineseLink': chineseLink, + 'codelessLink': codelessLink, + 'previewURL': previewURL, + 'playTime': playTime, + 'ratio': ratio, + 'resolution': resolution, + 'size': size, + 'downloadAllow': downloadAllow, + // 计价 / 权限 + 'coins': coins, + 'originCoins': originCoins, + 'permission': permission, + 'freeArea': freeArea, + 'freeTime': freeTime, + 'previewStart': previewStart, + 'showFreeTrialBadge': showFreeTrialBadge, + 'freeTrialRemaining': freeTrialRemaining, + 'canUseFreeTrial': canUseFreeTrial, + 'vidStatus': vidStatus?.toJson(), + // 统计 + 'hot': hot, + 'likeCount': likeCount, + 'collectCount': collectCount, + 'commentCount': commentCount, + 'playCount': playCount, + 'pageViewCount': pageViewCount, + 'videoCount': videoCount, + 'purchaseCount': purchaseCount, + // 种子 + 'seedLink': seedLink, + 'seedLinkUrl': seedLinkUrl, + 'seedSize': seedSize, + 'seedPlayTime': seedPlayTime, + // 动漫 / 漫画 / 短剧 + 'totalEpisode': totalEpisode, + 'updateStatus': updateStatus, + //短剧缓存记录靠它显示「第N集」,不写回读盘后集数就丢了 + 'episodeNo': episodeNo, + // 广告 + 'linkUrl': linkUrl, + // 视频分类 + 'videoTypeId': videoTypeId, + 'videoTypeName': videoTypeName, + }; + + // ===== 播放地址 ===== + + /// 拼 m3u8 完整播放链接:各播放源共用同一套 host / token / cdn 参数。 + /// 后端偶尔直接下发完整链接(265、短剧下载授权地址),那种别再往前面拼一遍 host + String _m3u8(String? path) { + if (path != null && + (path.startsWith('http://') || path.startsWith('https://'))) + return path; + return "${Address.baseApiPath}/vid/h5/m3u8/$path?token=${Address.token}&c=${Address.cdnAddress}"; + } + + /// 源视频播放 m3u8 完整链接(H.264,兜底) + String get realVideoUrl => _m3u8(sourceURL); + + /// 无码(破解版)m3u8 完整链接 + String get realUncodeUrl => _m3u8(codelessLink); + + /// 中文字幕版 m3u8 完整链接 + String get realChineseUrl => _m3u8(chineseLink); + + /// 预览片段 m3u8 完整链接 + String get realPreviewUrl => _m3u8(previewURL); + + /// H.265 播放 m3u8 完整链接。h265Url 已是完整 http 链接则直接用 + String get realH265Url { + final u = h265Url ?? ''; + if (u.isEmpty) return ''; + return u.startsWith('http') ? u : _m3u8(u); + } + + /// 详情页最终播放 URL(整合各分支,优先级从上到下): + /// - playContentStyle 2 且有中字链接 → 中字源 + /// - playContentStyle 3 且有无码链接 → 无码源 + /// - 命中预览权 [canPlayPreview] → 预览片段 + /// - 普通源:[useH265] 且有 265 地址 → 265(省带宽),否则 264(兜底) + /// [useH265] 由调用方算好传入(设备硬解能力 + 本条是否已运行时回退),model 不依赖 CodecSupport + String realPlayUrl({required bool canPlayPreview, required bool useH265}) { + if (playContentStyle == 2 && chineseLink?.isNotEmpty == true) + return realChineseUrl; + if (playContentStyle == 3 && codelessLink?.isNotEmpty == true) + return realUncodeUrl; + if (canPlayPreview) return realPreviewUrl; + if (useH265 && realH265Url.isNotEmpty) return realH265Url; + return realVideoUrl; + } + + // ===== 派生取值 ===== + + /// 展示用创建时间:createdAt 为空或后端占位值时退回审核通过时间 + String? get realCreateAt { + if (createdAt?.contains("0001-01-01") == true || + createdAt?.isNotEmpty != true) { + return reviewAt; + } + return createdAt; + } + + /// 当前播放秒数是否处于免费试看区间 [previewStart, previewStart + freeTime] + bool isInFreeTime(int sec) { + final start = previewStart ?? 0; + final end = start + (freeTime ?? 0); + return sec >= start && sec < end; + } + + bool get isDarkTag => + tags?.any((e) => e.name?.contains("暗网") == true) ?? false; + + String get allTags => tags?.map((e) => e.name).join(' ') ?? ''; + + /// 是否已点赞(vidStatus 为 null 时视为 false) + bool get hasLiked => vidStatus?.hasLiked ?? false; + + /// 是否已收藏(vidStatus 为 null 时视为 false) + bool get hasCollected => vidStatus?.hasCollected ?? false; + + /// 下载进度 0~1 + double get progress => (double.tryParse(loadProgress ?? '') ?? 0) / 100; + + /// 下载任务是否正在跑("2" 是已完成,不算跑) + bool get isDownloading => isLoaderRunning == "1"; + + /// 免费下载(downloadAllow 2):不校验 VIP / 次数,图标也换免费那套 + bool get isFreeDownload => downloadAllow == 2; + + String get seedSizeDesc => "${seedSize}Mb"; + + String get seedPlayTimeDesc { + final count = seedPlayTime ?? 0; + return count > 0 ? DateTimeUtil.formatHMS(count) : ""; + } + + String get seedRealLink { + if (seedLinkUrl?.isNotEmpty == true) { + var linkArr = seedLinkUrl?.split("[种子链接]:") ?? []; + if (linkArr.length == 2) { + return linkArr.last; + } + linkArr = seedLinkUrl?.split("[下载链接]:") ?? []; + if (linkArr.length == 2) { + return linkArr.last; + } + } + return seedLinkUrl ?? ""; + } + + bool isRandomAd() => randomAdsInfo != null; + + bool isAdsArr() => adsInfoArr != null; + + /// 是否需要金币购买(coins 或 originCoins 大于 0) + bool isCoinVideo() => (coins ?? 0) > 0 || (originCoins ?? 0) > 0; + + /// 实际售价:VIP 走折扣价,非 VIP 走原价 + int? get realCoins => globalStore.isVIP ? coins : originCoins; + + int videoCoin() => realCoins ?? 0; + + //评论类型,传参数类型 + String get commentType => 'video'; + + //集数状态描述 + String get episodeNumberStatus => mediaInfo?.episodeNumberStatus ?? ''; + + /// 动漫/漫画的更新状态描述:updateStatus==1 显示"更新N集/话",其他状态一律"已完结" + String get updateDesc { + final unit = mediaType == 'image' ? '话' : '集'; + if (updateStatus == 1) return '更新$totalEpisode$unit'; + return '已完结'; + } +} + +/// 图集与黄游内容的解锁规则扩展 +extension LockType on VideoModel { + /// 解锁样式(图集与黄游共用同一套规则):0-不需要解锁 1-金币解锁 2-会员解锁 + int get lockType { + //1.免费 + if (freeArea == true) return 0; + if (globalStore.isSuperUp) return 0; + //2.自己发布 + if (globalStore.isMe(publisher?.uid)) return 0; + //3.金币解锁 + if ((originCoins ?? 0) > 0) { + //是否购买 + if (vidStatus?.hasPaid == true) return 0; + //是否免费 + if (coins == 0) return 0; + return 1; + } + //vip解锁 + if (globalStore.isRechargeVIP) return 0; + return 2; + } +} + +/// 帖子流相关的判断与封面取值扩展(社区/个人主页等场景使用) +extension Post on VideoModel { + bool isVideo() { + return newsType == 'SP' || + newsType == 'MOVIE' || + newsType == 'SHORT' || + newsType == 'COVER'; + } + + bool isSeedPost() { + return newsType == 'ADULT_GAME' || + (seedLink?.isNotEmpty ?? false) || + newsType == 'SEED_LINK'; + } +} + +/// 当前用户对该视频的状态:是否点赞/收藏/购买/已读等 +class VidStatus { + bool? hasCollected; + bool? hasLiked; + bool? hasPaid; + int? todayPlayCnt; + int? todayRank; + bool? hasDisliked; + bool? hasPaidSeed; + bool? hasGraded; + bool? hasAddBookshelf; + bool? hasRead; + + static VidStatus fromMap(Map? map) { + map ??= {}; + VidStatus info = VidStatus(); + info.hasCollected = map['hasCollected']; + info.hasLiked = map['hasLiked']; + info.hasPaid = map['hasPaid']; + info.todayPlayCnt = map['todayPlayCnt']; + info.todayRank = map['todayRank']; + info.hasDisliked = map['hasDisliked']; + info.hasPaidSeed = map['hasPaidSeed']; + info.hasGraded = map['hasGraded']; + info.hasAddBookshelf = map['hasAddBookshelf']; + info.hasRead = map['hasRead']; + return info; + } + + Map toJson() => { + "hasDisliked": hasDisliked, + "hasCollected": hasCollected, + "hasLiked": hasLiked, + "hasPaid": hasPaid, + "todayPlayCnt": todayPlayCnt, + "todayRank": todayRank, + "hasPaidSeed": hasPaidSeed, + "hasGraded": hasGraded, + "hasAddBookshelf": hasAddBookshelf, + "hasRead": hasRead, + }; +} + +/// 标签 / 话题 / 分类公共模型(视频标签、帖子话题、ACG 分类共用) +class TagsBean { + String? coverImg; + String? description; + bool? hasCollected; + String? id; + String? name; + int? playCount; + int? type; + String? hotMark; + String? tagName; + int? videoCount; + int? collCount; + bool isSelected = false; + int? vidCount; + int? followCount; + + /// 客户端本地态,故意不参与 fromMap / toJson:由跳转方设定,决定标签页拉哪类内容。 + /// CommunityTagLogic 请求到新 tag 后会把它手动搬回来(见该 logic 的 onReady),别当僵尸字段删掉 + String? newsType; + + TagsBean({this.id, this.name}); + + //兼容name和tagname,后台返回不一致 + String? get mergeName => tagName?.isNotEmpty == true ? tagName : name; + + TagsBean.fromMap(Map? map) { + map ??= {}; + vidCount = map['vidCount']; + followCount = map['followCount']; + hotMark = map['hotMark']; + tagName = (map['tagName'] as String?)?.trim(); + videoCount = map['videoCount']; + coverImg = map['coverImg']; + description = map['description']; + hasCollected = map['hasCollected']; + id = map['id']; + name = (map['name'] as String?)?.trim(); + type = map['type']; + playCount = map['playCount']; + collCount = map['collCount']; + } + + Map toJson() => { + "coverImg": coverImg, + "description": description, + "hasCollected": hasCollected, + "id": id, + "name": name, + "playCount": playCount, + "tagName": tagName, + }; +} + +/// 视频/帖子发布者信息(用户基础资料 + VIP / 认证 / 活跃度等) +class Publisher { + int? age; + String? gender; + bool? hasFollowed; + String? name; + String? portrait; + int? uid; + int? vipLevel; //月卡 季卡 年卡 + bool? isVip; + bool? superUser; //大v + int? activeValue; //活跃值 + bool? officialCert; //官方认证 + int? rechargeLevel; //vip等级 vip1-vip8 + int? fans; + int? merchantUser; //认证商户 + List awards = []; + String? upTag; + int? totalWorks; + String? vipName; + + Publisher(); + + Publisher.fromMap(Map? map) { + map ??= {}; + if (map['awards'] is List) { + awards = [...(map['awards'] as List).map((o) => o)]; + } + totalWorks = map['totalWorks']; + upTag = map['upTag']; + merchantUser = map['merchantUser']; + age = map['age']; + gender = map['gender']; + hasFollowed = map['hasFollowed']; + name = map['name']; + portrait = map['portrait']; + uid = map['uid']; + vipLevel = map['vipLevel']; + isVip = map['isVip']; + if (map['superUser'] is bool) { + superUser = map['superUser']; + } else if (map['superUser'] is int) { + superUser = map['superUser'] >= 1; + } + activeValue = map['activeValue']; + officialCert = map['officialCert']; + rechargeLevel = map['rechargeLevel']; + fans = map['fans']; + vipName = map['vipName']; + } + + Map toJson() => { + "age": age, + "gender": gender, + "hasFollowed": hasFollowed, + "name": name, + "portrait": portrait, + "uid": uid, + "vipLevel": vipLevel, + "isVip": isVip, + "superUser": superUser, + "activeValue": activeValue, + "officialCert": officialCert, + "rechargeLevel": rechargeLevel, + }; +} + +/// 详情页热评(取一条最热评论附带在视频/帖子主体里展示) +class CommentBean { + int? uid; + String? name; + String? portrait; + String? cid; + String? content; + int? likeCount; + bool? isAuthor; + String? createdAt; + + CommentBean.fromMap(Map? map) { + map ??= {}; + uid = map['uid']; + name = map['name']; + portrait = map['portrait']; + cid = map['cid']; + content = map['content']; + likeCount = map['likeCount']; + isAuthor = map['isAuthor']; + createdAt = map['createdAt']; + } + + Map toJson() => { + "uid": uid, + "name": name, + "portrait": portrait, + "cid": cid, + "content": content, + "likeCount": likeCount, + "isAuthor": isAuthor, + "createdAt": createdAt, + }; +} diff --git a/lib/hj_page/ai/ai_change_face_logic.dart b/lib/hj_page/ai/ai_change_face_logic.dart new file mode 100644 index 0000000..f235cb5 --- /dev/null +++ b/lib/hj_page/ai/ai_change_face_logic.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/api_service/ai_service.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +import 'ai_sub_type/ai_function_logic.dart'; +import 'models/ai_mod_list_model.dart'; + +class AIFaceCategoryLogic extends ListBaseLogic { + final AiType type; //图片换脸 / 视频换脸 + + TabController? tabCtr; + + AIFaceCategoryLogic({this.type = AiType.imageChangeFace}); + + @override + void onReady() { + super.onReady(); + loadData(); + } + + @override + void onClose() { + tabCtr?.dispose(); + super.onClose(); + } + + void loadData() async { + dataList ??= []; + final resp = await AIService.getModelListV2(type: type); + if (resp != null) { + dataList?.addAll(resp.categoryList ?? []); + // 重新创建前先释放旧 controller,避免重复 loadData 时泄漏 + tabCtr?.dispose(); + tabCtr = TabController(length: dataList?.length ?? 0, vsync: this); + } + update(); + } +} diff --git a/lib/hj_page/ai/ai_change_face_page.dart b/lib/hj_page/ai/ai_change_face_page.dart new file mode 100644 index 0000000..9b76fd0 --- /dev/null +++ b/lib/hj_page/ai/ai_change_face_page.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../../assets_tool/app_colors.dart'; +import '../../tools_base/indicator/custom_tab_indicator.dart'; +import 'ai_change_face_logic.dart'; +import 'ai_face_sub_page.dart'; +import 'ai_sub_type/ai_function_logic.dart'; + +/// AI 换脸首页:根据 type 区分图片换脸 / 视频换脸,按分类列出 Tab,每 Tab 一个子页 +class AIChangeFacePage extends StatelessWidget { + final AiType type; //图片换脸 / 视频换脸 + const AIChangeFacePage({super.key, this.type = AiType.imageChangeFace}); + + // 图片/视频换脸两个子页作为 AI 首页并存 Tab 同时存活,用 type 做 tag 隔离两个 logic 实例 + String get _tag => 'ai_change_face_${type.name}'; + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: _tag, + init: AIFaceCategoryLogic(type: type), + builder: (logic) { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.isEmptyData) return CErrorWidget(); + return Column( + children: [ + 12.sizeBoxH, + _buildTabbar(logic), + 12.sizeBoxH, + Expanded( + child: TabBarView( + controller: logic.tabCtr, + children: List.generate( + logic.dataList?.length ?? 0, + (index) => AIFaceChangeSubPage( + type: type, //图片换脸 / 视频换脸 + mod: logic.dataList?[index], + ).keepAlive, + ), + ), + ) + ], + ); + }, + ); + } + + Widget _buildTabbar(AIFaceCategoryLogic logic) { + // 内层 GetBuilder 仅做局部刷新,复用同 tag 的 logic 实例 + return GetBuilder( + tag: _tag, + id: 'tab', + builder: (_) { + return TabBar( + padding: EdgeInsets.symmetric(horizontal: 16), + tabAlignment: TabAlignment.start, + controller: logic.tabCtr, + isScrollable: true, + onTap: (value) => logic.update(['tab']), + tabs: List.generate( + logic.dataList?.length ?? 0, + (index) => Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(logic.dataList?[index].name ?? ''), + Container( + margin: EdgeInsets.symmetric(horizontal: 14), + width: 1, + height: 8, + color: (index != ((logic.dataList?.length ?? 0) - 1)) + ? Colors.white.withValues(alpha: .3) + : Colors.transparent, + ) + ], + ), + ), + labelPadding: EdgeInsets.symmetric(horizontal: 0, vertical: 4), + labelColor: Colors.white, + labelStyle: const TextStyle(fontSize: 12), + unselectedLabelColor: Colors.white.withValues(alpha: 0.70), + unselectedLabelStyle: const TextStyle(fontSize: 12), + indicatorPadding: EdgeInsets.only(right: 30), + indicator: CustomIndicator( + width: 16, + height: 4, + borderRadius: BorderRadius.circular(16), + color: AppColors.actionRed, + ), + ); + }, + ); + } +} diff --git a/lib/hj_page/ai/ai_draw/ai_paint_logic.dart b/lib/hj_page/ai/ai_draw/ai_paint_logic.dart new file mode 100644 index 0000000..c5ccb9a --- /dev/null +++ b/lib/hj_page/ai/ai_draw/ai_paint_logic.dart @@ -0,0 +1,90 @@ +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../../../alert/mine/vip_level_dialog.dart'; +import '../../../config/config.dart'; +import '../../../hj_utils/api_service/ai_service.dart'; +import '../../../tools_base/global_store/store.dart'; +import '../../../tools_base/loading/loading_helper.dart'; +import '../ai_sub_type/ai_function_logic.dart'; +import '../models/ai_change_face_video_model.dart'; +import '../widgets/ai_mod_cell.dart'; + +/// ai绘画 +class AiPaintLogic extends AiFunctionBaseLogic { + late final TextEditingController titleCtr = TextEditingController(); + final aspectRatios = ["1:1", "4:3", "3:4", "16:9", "9:16"]; + int selectIndex = 0; //比例选择 + bool isFold = false; + int chooseItemIndex = 0; //图片选择 + + List models = []; + + AiPaintLogic(super.modList); + + @override + void onInit() { + super.onInit(); + models = modList?.aiTextToImgMod ?? []; + } + + @override + void onClose() { + titleCtr.dispose(); + super.onClose(); + } + + @override + Future submit() async { + if ((globalStore.wallet?.amount ?? 0) < + (int.tryParse(Config.aiDrawPrice) ?? 0)) { + showVipLevelDialog( + "当前金币不足", + buttonTitle: '我知道了', + desc: '充值金币 即可继续生成', + vipEvent: () => Get.back(), + ); + return; + } + if (titleCtr.text.isEmpty) { + showToast('请输入绘画描述~'); + return; + } + if (models.isEmpty) { + showToast('暂无模版,请退出重试'); + return; + } + + LoadingHelper.showLoading(); + final result = await AIService.generateTextToImage( + aspectRatios[selectIndex], + models[chooseItemIndex].styleType ?? 0, + titleCtr.text, + shareStatus: shareToAiSquare, + shareTitle: titleCtr.text, + ); + LoadingHelper.dismissLoading(); + if (result) { + showToast("提交成功~"); + selectIndex = 0; + chooseItemIndex = 0; + isFold = false; + titleCtr.text = ''; + update(); + } else { + showToast("提交失败"); + } + } + + Widget instanceChild(int index) { + return AIModCell( + mod: models[index], + isSelected: chooseItemIndex == index, + onTap: () { + chooseItemIndex = index; + update(); + }, + ); + } +} diff --git a/lib/hj_page/ai/ai_draw/ai_paint_page.dart b/lib/hj_page/ai/ai_draw/ai_paint_page.dart new file mode 100644 index 0000000..fe1f96c --- /dev/null +++ b/lib/hj_page/ai/ai_draw/ai_paint_page.dart @@ -0,0 +1,300 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../tools_base/widget/net_image_widget.dart'; +import '../models/ai_mod_list_model.dart'; +import '../widgets/ai_draw_text_field.dart'; +import 'ai_paint_logic.dart'; + +//ai绘画页面 +class AiPaintPage extends StatelessWidget { + final AiModList? aiModList; + + const AiPaintPage({super.key, this.aiModList}); + + @override + Widget build(BuildContext context) { + return Container( + color: Color(0xff030F18), + child: GetBuilder( + init: AiPaintLogic(aiModList), + builder: (logic) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 12.sizeBoxH, + Row( + children: [ + Text( + "形象描述(必填)", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + 10.sizeBoxH, + Container( + margin: EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.all(Radius.circular(5)), + boxShadow: [ + BoxShadow( + color: Colors.white.withValues(alpha: .05), + blurRadius: 1, //阴影模糊程度 + spreadRadius: 1.0, //阴影扩散程度 + ) + ], + ), + child: AIDrawTextField( + controller: logic.titleCtr, + hintText: "示例:“女,大学生,身高165cm,体重50kg,穿着JK制服.”", + maxLength: 100, + height: 112, + ), + ), + 14.sizeBoxH, + Text( + "选择长宽比例", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + 20.sizeBoxH, + SizedBox( + height: 28, + child: SingleChildScrollView( + child: Row( + children: List.generate( + logic.aspectRatios.length, + (index) => Padding( + padding: EdgeInsets.only(right: 12), + child: InkWell( + enableFeedback: false, + onTap: () { + logic.selectIndex = index; + logic.update(); + }, + child: Container( + padding: + EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: logic.selectIndex == index + ? [ + AppColors.actionRed, + AppColors.actionRed + ] + : [ + Colors.white + .withValues(alpha: .05), + Colors.white + .withValues(alpha: .05), + ]), + borderRadius: BorderRadius.circular(4), + color: logic.selectIndex == index + ? null + : Color(0xFF7C7C7C), + ), + height: 28, + child: Text( + logic.aspectRatios[index], + style: TextStyle( + color: logic.selectIndex == index + ? Colors.white + : Colors.white + .withValues(alpha: .5), + fontSize: logic.selectIndex == index + ? 14 + : 12, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ), + ), + ), + ), + ), + 18.sizeBoxH, + 0.5.line, + 18.sizeBoxH, + Container( + padding: EdgeInsets.only(bottom: logic.isFold ? 0 : 10), + margin: EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.all(Radius.circular(5)), + boxShadow: [ + BoxShadow( + color: Colors.white.withValues(alpha: .05), + blurRadius: 1, //阴影模糊程度 + spreadRadius: 1.0, //阴影扩散程度 + ) + ], + ), + child: _getStyleView(logic), + ), + 18.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "处理一张照片的费用是 ", + style: TextStyle( + color: Color(0xff999999), + fontSize: 12, + ), + ), + Text( + '${(int.tryParse(Config.aiDrawPrice) ?? 0)}金币', + style: TextStyle( + color: AppColors.actionRed, + fontSize: 12, + ), + ) + ], + ), + InkWell( + enableFeedback: false, + onTap: () => logic.submit(), + child: Container( + height: 44, + margin: EdgeInsets.symmetric(vertical: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + "立即提交", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _getStyleView(AiPaintLogic logic) { + return logic.isFold + ? InkWell( + enableFeedback: false, + onTap: () { + logic.isFold = !logic.isFold; + logic.update(); + }, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10.0, vertical: 10), + child: Row( + children: [ + _buildStyleTitle(), + Spacer(), + NetworkImageLoader( + imageUrl: logic.models.length > 1 + ? logic.models[logic.chooseItemIndex].cover ?? "" + : '', + width: 32, + height: 32, + ), + 10.sizeBoxW, + Transform.rotate( + angle: pi, + child: Image.asset('ai_draw_narrow.png'.aiPath, width: 10), + ), + ], + ), + ), + ) + : Column( + children: [ + InkWell( + enableFeedback: false, + onTap: () { + logic.isFold = !logic.isFold; + logic.update(); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, vertical: 10), + child: Row( + children: [ + _buildStyleTitle(), + Spacer(), + Image.asset('ai_draw_narrow.png'.aiPath, width: 10), + ], + ), + ), + ), + GridView.builder( + physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + padding: EdgeInsets.symmetric(horizontal: 10), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 105 / 131, + ), + itemCount: logic.models.length, + itemBuilder: (context, index) { + return logic.instanceChild(index); + }, + ), + ], + ); + } + + _buildStyleTitle() { + return Row( + children: [ + Image.asset( + 'ai_draw_style.png'.aiPath, + width: 15, + color: Colors.white, + ), + 5.sizeBoxW, + Text( + "风格", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/ai/ai_face_sub_logic.dart b/lib/hj_page/ai/ai_face_sub_logic.dart new file mode 100644 index 0000000..5cf76e3 --- /dev/null +++ b/lib/hj_page/ai/ai_face_sub_logic.dart @@ -0,0 +1,73 @@ +import 'package:hgdj/hj_utils/api_service/ai_service.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +import 'ai_sub_type/ai_function_logic.dart'; +import 'models/ai_mod_list_model.dart'; + +class AIFaceTemplateLogic extends ListBaseLogic { + final AICategoryMod? mod; + final AiType type; //图片换脸 / 视频换脸 + List sortType = ['上架时间', '使用次数', '价格排序']; //排序 + + int sortIndex = 0; //排序 0-上架时间, 1-使用次数, 2-价格排序 + bool ascending = true; //是否是升序 + + int get crossAxisCount => type.isVideoFace ? 2 : 3; + + double get spacing => type.isVideoFace ? 7 : 5; + + double get ratio => type.isVideoFace ? 168 / 95 : 111 / 148; + + AIFaceTemplateLogic({this.mod, this.type = AiType.imageChangeFace}); + + @override + void onReady() { + super.onReady(); + loadData(); + } + + void loadData() async { + dataList ??= []; + final resp = + await AIService.getModelListV2(type: type, categoryId: mod?.id ?? ''); + if (resp != null) { + dataList?.addAll(resp.templateList ?? []); + //默认上架时间升序 + dataList?.sort((obj1, obj2) => obj1.timestamp.compareTo(obj2.timestamp)); + } + update(); + } + + //排序 + void changeSortAction(int index) { + if (index == sortIndex) { + ascending = !ascending; + } else { + sortIndex = index; + ascending = true; + } + //本地排序 + switch (sortIndex) { + case 0: + dataList?.sort( + (obj1, obj2) => ascending + ? obj1.timestamp.compareTo(obj2.timestamp) + : obj2.timestamp.compareTo(obj1.timestamp), + ); + case 1: + dataList?.sort( + (obj1, obj2) => ascending + ? (obj1.usedCount ?? 0).compareTo(obj2.usedCount ?? 0) + : (obj2.usedCount ?? 0).compareTo(obj1.usedCount ?? 0), + ); + case 2: + dataList?.sort( + (obj1, obj2) => ascending + ? (obj1.coin ?? 0).compareTo(obj2.coin ?? 0) + : (obj2.coin ?? 0).compareTo(obj1.coin ?? 0), + ); + default: + } + update(); + } +} diff --git a/lib/hj_page/ai/ai_face_sub_page.dart b/lib/hj_page/ai/ai_face_sub_page.dart new file mode 100644 index 0000000..a956636 --- /dev/null +++ b/lib/hj_page/ai/ai_face_sub_page.dart @@ -0,0 +1,128 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../assets_tool/app_colors.dart'; +import 'ai_face_sub_logic.dart'; +import 'ai_mod_detail_page.dart'; +import 'ai_sub_type/ai_function_logic.dart'; +import 'models/ai_mod_list_model.dart'; + +/// AI 换脸分类子页:顶部 3 个排序按钮 + 网格列表,按 type 区分图片/视频换脸 +class AIFaceChangeSubPage extends StatelessWidget { + final AICategoryMod? mod; + final AiType type; //图片换脸 / 视频换脸 + const AIFaceChangeSubPage( + {super.key, this.mod, this.type = AiType.imageChangeFace}); + + // 多分类子页作为 TabBarView 的 keepAlive children 并存,用 type+分类id 隔离各自 logic 实例 + String get _tag => 'ai_face_sub_${type.name}_${mod?.id}'; + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: _tag, + init: AIFaceTemplateLogic(mod: mod, type: type), + builder: (logic) { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.isEmptyData) { + return CErrorWidget(retryOnTap: () => logic.loadData()); + } + return Column( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: _buildSortView(logic), + ), + 12.sizeBoxH, + Expanded( + child: GridView.builder( + padding: EdgeInsets.fromLTRB(16, 0, 16, 20), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: logic.crossAxisCount, + mainAxisSpacing: logic.spacing, + crossAxisSpacing: logic.spacing, + childAspectRatio: logic.ratio, + ), + itemCount: logic.dataList?.length ?? 0, + itemBuilder: (context, index) { + TemplateModel? model = logic.dataList?[index]; + return InkWell( + enableFeedback: false, + onTap: () => Get.to(AIModDetailPage(model!)), + child: NetworkImageLoader(imageUrl: model?.cover ?? ''), + ); + }, + ), + ), + ], + ); + }, + ); + } + + Widget _buildSortView(AIFaceTemplateLogic logic) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildSortItem(logic.sortType[0], logic.ascending, 0, logic), + 20.sizeBoxW, + _buildSortItem(logic.sortType[1], logic.ascending, 1, logic), + 20.sizeBoxW, + _buildSortItem(logic.sortType[2], logic.ascending, 2, logic), + ], + ); + } + + Widget _buildSortItem( + String title, bool ascending, int sortIndex, AIFaceTemplateLogic logic) { + final bool sel = logic.sortIndex == sortIndex; + return InkWell( + enableFeedback: false, + onTap: () => logic.changeSortAction(sortIndex), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 3), + decoration: BoxDecoration( + color: sel ? AppColors.actionRed : Color(0x1AFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: Row( + children: [ + Text( + title, + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w500, + height: 1.5), + ), + 5.sizeBoxW, + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildArrow(active: sel && ascending), + 4.sizeBoxH, + Transform.rotate( + angle: -pi, child: _buildArrow(active: sel && !ascending)), + ], + ), + ], + ), + ), + ); + } + + Widget _buildArrow({required bool active}) { + return Image.asset( + 'ai_narrow.png'.aiPath, + width: 4.8, + color: active ? Color(0xffFFD460) : null, + ); + } +} diff --git a/lib/hj_page/ai/ai_girl/ai_girl_friend_logic.dart b/lib/hj_page/ai/ai_girl/ai_girl_friend_logic.dart new file mode 100644 index 0000000..e3947f3 --- /dev/null +++ b/lib/hj_page/ai/ai_girl/ai_girl_friend_logic.dart @@ -0,0 +1,77 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/ai_service.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/loading/loading_alert_widget.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../models/ai_girl_resp_model.dart'; +import '../widgets/ai_girl_sheet.dart'; +import 'ai_h5_page.dart'; + +class AIGrilFriendLogic extends GetxController { + AIGirlFriendBalanceModel? balanceModel; + AIGirlFriendUrlModel? urlModel; + AIGirlFriendCurrencys? currencys; + RxBool open = true.obs; + RxString tips = ''.obs; + + @override + void onReady() { + super.onReady(); + loadData(); + onFetchChargeList(); + } + + loadData() async { + try { + balanceModel = await AIService.getBalance({}); + open.value = true; + } catch (e) { + debugLog(e); + open.value = false; + } + update(['balance']); + } + + //跳转url + onFetchJumpUrl() async { + try { + LoadingAlertWidget.show(); + urlModel = await AIService.getMateUrl(); + LoadingAlertWidget.cancel(); + } catch (e) { + LoadingAlertWidget.cancel(); + debugLog(e); + } + if (urlModel?.url != null) { + await Get.to(AiH5Page(webUrl: urlModel?.url)); + loadData(); + } else { + showToast('未知链接'); + } + } + + //充值操作 + onRechargeBottomSheet() async { + if (currencys != null && currencys?.list?.isNotEmpty == true) { + Get.bottomSheet(AIGirlSheet(list: currencys?.list), + isScrollControlled: true); + } else { + await onFetchChargeList(); + if (currencys?.list?.isNotEmpty != true) { + showToast("暂未配置充值数据"); + } + } + } + + //获取充值列表 + Future onFetchChargeList({bool showLoading = false}) async { + if (showLoading) LoadingAlertWidget.show(); + try { + currencys = await AIService.getMateCurrencies(); + } catch (e) { + debugLog(e); + } + if (showLoading) LoadingAlertWidget.cancel(); + } +} diff --git a/lib/hj_page/ai/ai_girl/ai_girl_friend_page.dart b/lib/hj_page/ai/ai_girl/ai_girl_friend_page.dart new file mode 100644 index 0000000..64a3c7b --- /dev/null +++ b/lib/hj_page/ai/ai_girl/ai_girl_friend_page.dart @@ -0,0 +1,155 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; + +import '../../../hj_utils/widget_util.dart'; +import '../../../tools_base/unique_tag_mixin.dart'; +import 'ai_girl_friend_logic.dart'; + +// Ai女友 +class AIGrilFriendPage extends StatefulWidget { + const AIGrilFriendPage({super.key}); + + @override + State createState() => _AIGrilFriendPageState(); +} + +class _AIGrilFriendPageState extends State + with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: AIGrilFriendLogic(), + builder: (logic) => Scaffold( + body: Stack( + alignment: Alignment.center, + children: [ + Image.asset( + 'ai_girl_friend_bg.webp'.aiPath, + fit: BoxFit.fill, + width: Get.width, + height: Get.height, + ), + Positioned( + bottom: 125, + left: 10, + right: 10, + child: GestureDetector( + onTap: () => logic.onFetchJumpUrl(), + child: Image.asset( + 'ai_girl_friend_btn.webp'.aiPath, + ), + ), + ), + Positioned( + top: 0, + height: 101, + child: Stack( + children: [ + Image.asset( + 'ai_girl_friend_top.webp'.aiPath, + width: Get.width, + fit: BoxFit.fitWidth, + ), + _buildBanlance(logic), + ], + ), + ), + Positioned.fill( + child: Obx(() => logic.open.value + ? Container() + : Stack( + children: [ + Container( + color: Colors.black.withValues(alpha: .75), + child: Center( + child: Obx( + () => Text( + logic.tips.value, + style: + textStyle(17, Colors.white, FontWeight.w500), + ), + )), + ), + Positioned( + left: 10, + top: 60, + child: GestureDetector( + child: GestureDetector( + onTap: () => Get.back(), + child: const Icon(Icons.arrow_back_ios, + size: 24, color: Colors.white), + ), + ), + ) + ], + )), + ), + ], + ), + ), + ); + } + + Widget _buildBanlance(AIGrilFriendLogic logic) { + return Positioned( + left: 10, + right: 10, + bottom: 14, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + child: GestureDetector( + onTap: () => Get.back(), + child: const Icon(Icons.arrow_back_ios, + size: 24, color: Colors.white), + ), + ), + Container( + padding: EdgeInsets.all(1), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(100), + border: Border.all(color: Color(0xff2A5298)), + ), + child: Row( + children: [ + const SizedBox(width: 12), + GetBuilder( + id: 'balance', + builder: (_) { + if (_.balanceModel != null) { + return Text( + '积分:${_.balanceModel?.balance ?? 0}', + style: textStyle(18, Colors.white, FontWeight.w400), + ); + } else { + return const SizedBox( + width: 22, + child: CupertinoActivityIndicator( + color: Colors.white, + radius: 8, + ), + ); + } + }, + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () => logic.onRechargeBottomSheet(), + child: Image.asset( + 'ai_girl_friend_topup.webp'.aiPath, + width: 76, + ), + ) + ], + ), + ) + ], + ), + ); + } +} diff --git a/lib/hj_page/ai/ai_girl/ai_h5_page.dart b/lib/hj_page/ai/ai_girl/ai_h5_page.dart new file mode 100644 index 0000000..4fc3cb2 --- /dev/null +++ b/lib/hj_page/ai/ai_girl/ai_h5_page.dart @@ -0,0 +1,60 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; + +import '../../../hj_utils/widget_util.dart'; +import '../../web_page/h5_webview_settings.dart'; + +class AiH5Page extends StatefulWidget { + final String? webUrl; + const AiH5Page({super.key, this.webUrl}); + + @override + State createState() { + return _YHYSH5ViewState(); + } +} + +class _YHYSH5ViewState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Stack( + fit: StackFit.expand, + children: [ + InAppWebView( + initialUrlRequest: + URLRequest(url: WebUri(widget.webUrl ?? "")), //h5的url + initialSettings: h5WebViewSettings, + initialUserScripts: UnmodifiableListView([]), + ), + Positioned( + bottom: 70, + right: 10, + width: 44, + height: 44, + child: GestureDetector( + onTap: () => Get.back(), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.primaryHighColor, + borderRadius: BorderRadius.circular(22), + ), + child: Text( + '退出', + style: textStyle(14, Colors.black, FontWeight.w500), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/ai/ai_home_logic.dart b/lib/hj_page/ai/ai_home_logic.dart new file mode 100644 index 0000000..cd44bc7 --- /dev/null +++ b/lib/hj_page/ai/ai_home_logic.dart @@ -0,0 +1,98 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/api_service/ai_service.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../../config/config.dart'; +import '../../hj_model/splash/domain_source_model.dart'; +import './ai_change_face_page.dart'; +import './ai_draw/ai_paint_page.dart'; +import './ai_image_to_video/ai_main_itv_page.dart'; +import './ai_novel/ai_novel_page.dart'; +import './ai_sub_type/ai_function_logic.dart'; +import './ai_sub_type/ai_strip_sub_page.dart'; +import './models/ai_mod_list_model.dart'; +import 'ai_square/ai_square_page.dart'; + +class AiHomeLogic extends GetxController + with GetSingleTickerProviderStateMixin { + bool isShowLoading = true; + final AiType? aiType; + // int tabIndex; + + AiHomeLogic({this.aiType}); + + AiModList? aiModList; + + late List menus = []; + + late TabController tabCtr; + + // 跳转相关模块的亚模块 + void jumpSubModule(int index) { + tabCtr.index = index; + } + + @override + void onInit() { + super.onInit(); + List? aiTypes = Config.aiTypes; + //1.删除未开启的 + aiTypes?.removeWhere((model) => model.isOpen == false); + //2.排序 + aiTypes?.sort( + (m1, m2) => (m1.sort ?? 0).compareTo(m2.sort ?? 0), + ); + menus.addAll(aiTypes ?? []); + //3.获取到默认进来的排序 + int? initialIndex = aiTypes?.indexWhere((model) => model.aiType == aiType); + initialIndex = max(0, initialIndex ?? 0); + tabCtr = TabController( + initialIndex: initialIndex, length: menus.length, vsync: this) + ..addListener(() { + if (!tabCtr.indexIsChanging) { + update(['tab']); + } + }); + } + + @override + void onReady() { + super.onReady(); + presaleProvider.refreshStatus(); //刷新预售权益 + globalStore.refreshWallet(); //刷新钱包 + loadData(); + } + + void loadData() async { + aiModList = await AIService.getModelList(); + aiModList ??= AiModList(); + isShowLoading = false; + update(); + } + + Widget subPage(AISwitchConf model) { + switch (model.aiType) { + case AiType.imageToVideo: + return AiMainItvPage(aiModList: aiModList).keepAlive; //图生视频 + case AiType.aiPaint: + return AiPaintPage(aiModList: aiModList).keepAlive; //ai绘画 + case AiType.autoStrip: + return AIStripSubPage().keepAlive; //脱衣 + case AiType.videoChangeFace: + return AIChangeFacePage(type: AiType.videoChangeFace).keepAlive; //视频换脸 + case AiType.imageChangeFace: + return AIChangeFacePage(type: AiType.imageChangeFace).keepAlive; //图片换脸 + case AiType.aiNovel: + return AINovelSubPage().keepAlive; //ai小说 + case AiType.aiMate: + return AiSquarePage().keepAlive; //ai女友 + default: + return Container(); + } + } +} diff --git a/lib/hj_page/ai/ai_home_page.dart b/lib/hj_page/ai/ai_home_page.dart new file mode 100644 index 0000000..ef00bf8 --- /dev/null +++ b/lib/hj_page/ai/ai_home_page.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../../hj_utils/widget_util.dart'; +import 'ai_home_logic.dart'; +import 'ai_record/ai_main_record_page.dart'; + +/// AI 科技首页:顶部功能 tab 网格 + 各功能子页 +class AiHomePage extends StatelessWidget { + const AiHomePage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: AiHomeLogic(), + builder: (logic) { + return Stack( + children: [ + Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + title: Text('AI科技'), + backgroundColor: AppColors.primaryColor, + actions: [ + Center( + child: InkWell( + enableFeedback: false, + onTap: () => Get.to( + () => AIMainRecordPage( + aiType: logic.menus[logic.tabCtr.index].aiType, + ), + ), + child: Text( + '记录', + style: textStyle( + 14, + Colors.white.withValues(alpha: 0.55), + FontWeight.w400), + ), + ), + ), + 16.sizeBoxW + ], + ), + body: _buildContent(logic)) + ], + ); + }); + } + + /// 内容区:加载中 / 加载失败 / tab 网格 + 子页 + Widget _buildContent(AiHomeLogic logic) { + if (logic.isShowLoading) return const LoadingCenterWidget(); + if (logic.aiModList == null) { + return CErrorWidget(retryOnTap: () => logic.loadData()); + } + return Column( + children: [ + _buildTabBar(logic), + Expanded( + child: TabBarView( + controller: logic.tabCtr, + children: logic.menus + .map((e) => logic.subPage(e).keepAlive) + .toList(), + ), + ) + ], + ); + } + + /// 顶部功能 tab 网格(id:'tab' 局部刷新选中态) + Widget _buildTabBar(AiHomeLogic logic) { + return GetBuilder( + init: logic, + id: 'tab', + builder: (_) { + return GridView.builder( + shrinkWrap: true, + padding: EdgeInsets.symmetric(horizontal: 16), + physics: NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 105 / 26, + crossAxisSpacing: 14, + mainAxisSpacing: 14, + ), + itemCount: logic.menus.length, + itemBuilder: (context, index) { + final isSelected = logic.tabCtr.index == index; + final menu = logic.menus[index]; + return InkWell( + enableFeedback: false, + onTap: () { + logic.tabCtr.animateTo(index); + logic.update(['tab']); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: isSelected + ? Color(0xffF68804) + : Colors.white.withValues(alpha: .1), + border: isSelected + ? null + : Border.all( + width: 1, color: Colors.white.withValues(alpha: .35)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + menu.img ?? '', + width: 16, + color: Colors.white, + ), + 4.sizeBoxW, + Text( + menu.aiTypeName ?? '', + style: textStyle(12, Colors.white, FontWeight.w400), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/hj_page/ai/ai_image_to_video/ai_itv_logic.dart b/lib/hj_page/ai/ai_image_to_video/ai_itv_logic.dart new file mode 100644 index 0000000..ae3c1eb --- /dev/null +++ b/lib/hj_page/ai/ai_image_to_video/ai_itv_logic.dart @@ -0,0 +1,78 @@ +// 图片转视频 +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/api_service/ai_service.dart'; +import 'package:hgdj/tools_base/file_upload/file_upload_tool.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_alert_widget.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; + +import '../../../alert/mine/vip_level_dialog.dart'; +import '../../../hj_model/splash/ads_model.dart'; + +class AiItvLogic extends GetxController { + final localPicList = []; //已选本地图片路径,与 PicPicker 共享 + final AdsInfoModel? model; + + double get aspectRatio => 408 / 310; + bool shareToAiSquare = true; + + /// 图片转视频模版 + TextEditingController editingCtr = TextEditingController(); + + AiItvLogic({this.model}); + + Future submit() async { + if ((globalStore.wallet?.amount ?? 0) < + (int.tryParse(Config.aiVideoPrice) ?? 0)) { + showVipLevelDialog( + "当前金币不足", + buttonTitle: '我知道了', + desc: '充值金币 即可继续生成', + vipEvent: () => Get.back(), + ); + return; + } + aiImageToVideo(); + } + + //AI 图片转视频 + aiImageToVideo() async { + if (localPicList.isEmpty) { + CommonAlert.show(title: "提示", content: "请选择图片", showCancel: false); + return; + } + await FileUploadTool().uploadImagesWithProgress( + localPicList, + onFailure: () => showToast("图片上传失败"), + onSuccess: (imageArr) async { + LoadingAlertWidget.show(title: "正在更新数据..."); + try { + final result = await AIService.generateImgVideo( + imageArr.first, + shareStatus: shareToAiSquare, + shareTitle: editingCtr.text, + mid: model?.id, + ); + if (result) { + showToast("提交成功~"); + localPicList.clear(); + globalStore.refreshWallet(); + update(); + } else { + showToast("提交失败"); + } + } on DioException catch (e) { + showToast(e.message.toString()); + } catch (e) { + showToast(e.toString()); + } finally { + LoadingAlertWidget.cancel(); + } + }, + ); + } +} diff --git a/lib/hj_page/ai/ai_image_to_video/ai_itv_page.dart b/lib/hj_page/ai/ai_image_to_video/ai_itv_page.dart new file mode 100644 index 0000000..aae0654 --- /dev/null +++ b/lib/hj_page/ai/ai_image_to_video/ai_itv_page.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/banner/ads_banner_widget.dart'; + +import '../../../hj_model/splash/ads_model.dart'; +import '../widgets/pic_picker.dart'; +import 'ai_itv_logic.dart'; + +class AiItvPage extends StatelessWidget { + final AdsInfoModel? model; + const AiItvPage({super.key, this.model}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Color(0xff030F18), + appBar: AppBar( + title: Text( + model?.title ?? '图生视频', + style: TextStyle( + color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + ), + centerTitle: false, + titleSpacing: -20, + ), + body: GetBuilder( + init: AiItvLogic(model: model), + global: false, + builder: (logic) { + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(0, 12, 0, 0), + child: Row( + children: [ + PicPicker( + width: 111, + height: 111, + picList: logic.localPicList, + ), + SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "注意事项:", + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + 4.sizeBoxH, + Text( + '''1、素材仅供AI使用,绝无外泄风险,请放心使用. \n2、素材需清晰,小于2MB,上传间隔大于60秒. \n3、本功能不支持多人图片 \n4、生成失败退回金币,若违规作废. 5、禁止使用未成年图片!''', + style: TextStyle( + color: Color(0xff656565), + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + ), + 18.sizeBoxH, + 0.5.line, + 18.sizeBoxH, + Text( + "案列鉴赏", + style: TextStyle( + fontSize: 18, + color: Colors.white, + fontWeight: FontWeight.w600), + ), + 12.sizeBoxH, + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: AspectRatio( + aspectRatio: 343 / 224, + child: model == null + ? SizedBox.shrink() + : AIBannerWidget( + models: [model!], + ), + ), + ), + 18.sizeBoxH, + 0.5.line, + 18.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "处理一张照片的费用是 ", + style: TextStyle( + color: Colors.white.withValues(alpha: .6), + fontSize: 14), + ), + Text( + "${Config.aiVideoPrice}金币", + style: TextStyle( + color: AppColors.actionRed, + fontSize: 14, + ), + ), + ], + ), + 18.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () => logic.submit(), + child: Container( + height: 44, + // margin: EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + "立即提交", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + // AiShareToSquare( + // logic.editingCtr, + // valueChanged: (value) => logic.shareToAiSquare = value, + // ), + // 20.sizeBoxH, + ], + ), + ), + ), + ], + ); + }), + ); + } +} diff --git a/lib/hj_page/ai/ai_image_to_video/ai_main_itv_page.dart b/lib/hj_page/ai/ai_image_to_video/ai_main_itv_page.dart new file mode 100644 index 0000000..1661cc2 --- /dev/null +++ b/lib/hj_page/ai/ai_image_to_video/ai_main_itv_page.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../hj_model/splash/ads_model.dart'; +import '../../../tools_base/loading/loading_center_widget.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import '../models/ai_mod_list_model.dart'; +import 'ai_itv_page.dart'; + +/// AI 图生视频主页:模板两列网格 +class AiMainItvPage extends StatelessWidget { + final AiModList? aiModList; + const AiMainItvPage({super.key, this.aiModList}); + + @override + Widget build(BuildContext context) { + if (aiModList == null) return LoadingCenterWidget(); //数据未就绪 + final list = aiModList!.aiImgToVideoMod; + if (list == null || list.isEmpty) return CErrorWidget(); //无模板数据 + return GridView.builder( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 167 / 223, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: list.length, + itemBuilder: (context, index) => _buildItem(list[index]), + ); + } + + /// 单个模板卡片:封面图 + 底部标题胶囊 + Widget _buildItem(AdsInfoModel item) { + return InkWell( + enableFeedback: false, + onTap: () => Get.to(AiItvPage(model: item)), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: NetworkImageLoader(imageUrl: item.newUrl ?? ''), + ), + Positioned( + bottom: 6, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + gradient: LinearGradient(colors: [ + Color(0xffFF4D4D), + Color(0xffFF6E6E), + ]), + ), + child: Text( + item.title ?? '', + style: TextStyle(color: Colors.white, fontSize: 14), + ), + ), + ) + ], + ), + ); + } +} diff --git a/lib/hj_page/ai/ai_mod_detail_page.dart b/lib/hj_page/ai/ai_mod_detail_page.dart new file mode 100644 index 0000000..771d9dd --- /dev/null +++ b/lib/hj_page/ai/ai_mod_detail_page.dart @@ -0,0 +1,312 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../hj_model/video_model.dart'; +import '../../hj_utils/widget_util.dart'; +import '../mine/widgets/gradient_text.dart'; +import '../video/simple_video_player_page.dart'; +import 'ai_sub_type/ai_function_logic.dart'; +import 'models/ai_mod_list_model.dart'; +import 'widgets/pic_picker.dart'; + +//ai换脸模版详情页 +class AIModDetailPage extends StatelessWidget { + final TemplateModel modMod; + const AIModDetailPage(this.modMod, {super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: AiFaceDetailLogic(null, mod: modMod), + builder: (logic) { + return Scaffold( + appBar: AppBar( + title: Text(logic.mod.title ?? ''), + centerTitle: false, + titleSpacing: -10, + ), + body: Stack( + children: [ + Column( + children: [ + Expanded(child: _buildContent(logic)), + 72.sizeBoxH, + ], + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: InkWell( + enableFeedback: false, + onTap: () => logic.submit(), + child: Container( + height: 44, + alignment: Alignment.center, + margin: EdgeInsets.fromLTRB(16, 0, 16, 12), + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + '立即提交', + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ) + ], + ), + ); + }); + } + + Widget _buildContent(AiFaceDetailLogic logic) { + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 12, 16, 60), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + alignment: Alignment.center, + children: [ + AspectRatio( + aspectRatio: 343 / 193, + child: NetworkImageLoader( + imageUrl: logic.mod.cover ?? '', + borderRadius: 8, + fit: logic.isChangeVideo ? BoxFit.fitWidth : BoxFit.fitHeight, + ), + ), + //视频换脸 + if (logic.isChangeVideo) + InkWell( + enableFeedback: false, + onTap: () { + VideoModel videoModel = VideoModel() + ..sourceURL = logic.mod.m3u8Url; + Get.to( + SimpleVideoPlayerPage( + videoUrl: videoModel.realVideoUrl, + title: logic.mod.title ?? '', + ), + ); + }, + child: Image.asset( + 'circle_play.webp'.videoPath, + width: 50, + ), + ) + ], + ), + 18.sizeBoxH, + .5.line, + 18.sizeBoxH, + Text( + "注意事项:", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + 8.sizeBoxH, + Text( + '''1. 选择一张人脸清晰,不得有任何遮挡的照片上传(注意:只含一个人物和脸部,图片不能过暗) +2. 选择一个心仪的视频或图片模板,点击生成,生成时间需要3-5分钟,耐心等待。(图片模板可自行上传) +3. 在右上角记录查看生成进度,生成成功后可以点击进行下载,也可以在线观看。 +4. 按照上方操作,有问题随时联系在线客服进行处理。 +5. 不支持多人图片,禁止未成年人图片''', + style: TextStyle( + color: Color(0xff656565), + fontSize: 12, + height: 18 / 12, + ), + ), + 12.sizeBoxH, + _buildExample(), + 18.sizeBoxH, + 0.5.line, + 18.sizeBoxH, + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "上传脸部信息", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + 12.sizeBoxW, + Text( + "图片大小请低于2Mb", + style: TextStyle( + color: Color(0xff656565), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + 12.sizeBoxH, + PicPicker( + width: 111, + height: 111, + picList: logic.localPicList, + ), + 18.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [_priceInfo(logic)], + ), + ], + ), + ); + } + + // 免费次数 / 处理费用 / 抵扣券(视频换脸)信息,钱包变化用 Consumer 局部刷新 + Widget _priceInfo(AiFaceDetailLogic logic) { + return Consumer(builder: (_, provider, __) { + final total = provider.wallet?.aiUndressFreeTimes ?? 0; + final presaleCount = presaleProvider.remain?.todayAiUndressCount ?? 0; + final todayFreeCount = provider.wallet?.todayAiFreeTimes ?? 0; + //今日免费次数 + final toDaytotal = todayFreeCount + presaleCount; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!logic.isChangeVideo) ...[ + Text( + '你当前免费体验为$total次数', + style: TextStyle( + color: Color(0xff989898), + fontSize: 12, + ), + ), + 12.sizeBoxH, + Text( + '当日免费$toDaytotal次数', + style: TextStyle( + color: Color(0xff989898), + fontSize: 12, + ), + ), + ], + 12.sizeBoxH, + EasyRichText( + '处理一张照片的费用是 ${logic.price}金币', + defaultStyle: TextStyle( + color: Color(0xff989898), + fontSize: 14, + fontWeight: FontWeight.w400), + patternList: [ + EasyRichTextPattern( + targetString: '${logic.price}金币', + style: TextStyle(color: AppColors.actionRed), + ) + ], + ), + if (logic.isChangeVideo) ...[ + if (logic.coupon != null) ...[ + 12.sizeBoxH, + EasyRichText( + '已抵扣 ${logic.coupon?.goodsValue ?? 0}金币', + defaultStyle: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 14, + fontWeight: FontWeight.w400), + patternList: [ + EasyRichTextPattern( + targetString: '${logic.coupon?.goodsValue ?? 0}金币', + style: TextStyle(color: AppColors.actionRed), + ) + ], + ), + ], + 12.sizeBoxH, + Row( + children: [ + InkWell( + enableFeedback: false, + onTap: () => logic.showCoupon(), + child: GradientText( + logic.coupon == null ? '使用抵扣券' : '更换抵扣券', + gradient: LinearGradient( + colors: [ + Color(0xff35DEBC), + Color(0xff22BB9C), + ], + ), + style: TextStyle(fontSize: 14), + ), + ), + if (logic.coupon != null) ...[ + 12.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: () { + //取消优惠券 + logic.coupon = null; + logic.update(); + }, + child: Text( + '取消', + style: textStyle(14, Colors.white.withValues(alpha: .55), + FontWeight.w400), + ), + ) + ] + ], + ), + ], + ], + ); + }); + } + + Widget _buildExample() { + return Row(children: [ + _buildExampleItem('正面无遮挡', "ai_changeface_right.webp".aiPath), + 20.sizeBoxW, + _buildExampleItem('不遮挡脸部', "ai_changeface_wrong_1.webp".aiPath), + 20.sizeBoxW, + _buildExampleItem('不遮挡眼睛', "ai_changeface_wrong_2.webp".aiPath), + ]); + } + + Widget _buildExampleItem(String title, String img) { + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AspectRatio( + aspectRatio: 1, + child: Image.asset(img), + ), + 14.sizeBoxH, + Text( + title, + style: TextStyle( + color: Color(0xff989898), + fontSize: 14, + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/ai/ai_novel/ai_novel_detail_page.dart b/lib/hj_page/ai/ai_novel/ai_novel_detail_page.dart new file mode 100644 index 0000000..d1cf6f7 --- /dev/null +++ b/lib/hj_page/ai/ai_novel/ai_novel_detail_page.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +import '../models/ai_record_model.dart'; + +class AiNovelDetailPage extends StatelessWidget { + final AiRecordModel model; + const AiNovelDetailPage(this.model, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('小说详情')), + body: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 12), + child: Text( + model.content ?? '', + style: TextStyle(color: Colors.white.withValues(alpha: .8), fontSize: 14, height: 1.5), + ), + ), + ); + } +} diff --git a/lib/hj_page/ai/ai_novel/ai_novel_logic.dart b/lib/hj_page/ai/ai_novel/ai_novel_logic.dart new file mode 100644 index 0000000..ff0259e --- /dev/null +++ b/lib/hj_page/ai/ai_novel/ai_novel_logic.dart @@ -0,0 +1,56 @@ +//ai小说 +import 'package:flutter/material.dart'; +import 'package:hgdj/tools_base/loading/loading_helper.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../../../../hj_utils/api_service/ai_service.dart'; +import '../ai_sub_type/ai_function_logic.dart'; + +class AINovelLogic extends AiFunctionBaseLogic { + late final personTextCtr = TextEditingController(); + late final addressTextCtr = TextEditingController(); + late final detailTextCtr = TextEditingController(); + late final storylineTextCtr = TextEditingController(); + final mods = ['AI小艺', 'AI小萌']; + int selectIndex = 0; + + AINovelLogic(super.modList); + + @override + void onClose() { + personTextCtr.dispose(); + addressTextCtr.dispose(); + detailTextCtr.dispose(); + storylineTextCtr.dispose(); + super.onClose(); + } + + @override + Future submit() async { + if (storylineTextCtr.text.isEmpty) { + showToast('请输入故事情节描述~'); + return; + } + + LoadingHelper.showLoading(); + final result = await AIService.generateNovel( + storylineTextCtr.text, + characterSetting: personTextCtr.text, + locationScene: addressTextCtr.text, + details: detailTextCtr.text, + modelType: selectIndex + 1, + ); + LoadingHelper.dismissLoading(); + if (result) { + showToast('提交成功~'); + storylineTextCtr.clear(); + personTextCtr.clear(); + addressTextCtr.clear(); + detailTextCtr.clear(); + selectIndex = 0; + update(); + } else { + showToast('提交失败'); + } + } +} diff --git a/lib/hj_page/ai/ai_novel/ai_novel_page.dart b/lib/hj_page/ai/ai_novel/ai_novel_page.dart new file mode 100644 index 0000000..bff1ba3 --- /dev/null +++ b/lib/hj_page/ai/ai_novel/ai_novel_page.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../widgets/ai_draw_text_field.dart'; +import 'ai_novel_logic.dart'; + +class AINovelSubPage extends StatelessWidget { + const AINovelSubPage({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: Color(0xff030F18), + // 用默认 global:global:false 下 widget 销毁不会 Get.delete,onClose 不触发,输入框 controller 释放不掉 + child: GetBuilder( + init: AINovelLogic(null), + builder: (logic) { + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.symmetric(horizontal: 6).copyWith(top: 12), + child: Column( + children: [ + _buildInputView( + logic.personTextCtr, + ), + 12.sizeBoxH, + _buildInputView( + logic.addressTextCtr, + title: '地点场景(选填)', + hint: '示例:办公室、酒吧', + ), + 12.sizeBoxH, + _buildInputView( + logic.storylineTextCtr, + title: '故事情节(必填)', + hint: '故事大致情节,例如:35岁女强人在酒吧遇到跳钢管舞的男模后,欲火焚身、欲罢不能。', + maxLength: 200, + ), + 12.sizeBoxH, + _buildInputView( + logic.detailTextCtr, + title: '细节说明(选填)', + hint: '例如:请详细描写女主身材美貌,与霸总的做爱过程', + maxLength: 200, + ), + 12.sizeBoxH, + Align( + alignment: Alignment.centerLeft, + child: Text( + "选择模型", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + 10.sizeBoxH, + SizedBox( + height: 28, + child: SingleChildScrollView( + child: Row( + children: List.generate( + logic.mods.length, + (index) => Padding( + padding: EdgeInsets.only(right: 12), + child: InkWell( + enableFeedback: false, + onTap: () { + logic.selectIndex = index; + logic.update(); + }, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: logic.selectIndex == index + ? [ + AppColors.actionRed, + AppColors.actionRed + ] + : [ + Colors.white + .withValues(alpha: .05), + Colors.white + .withValues(alpha: .05), + ]), + borderRadius: BorderRadius.circular(4), + color: logic.selectIndex == index + ? null + : Color(0xFF7C7C7C), + ), + height: 28, + child: Text( + logic.mods[index], + style: TextStyle( + color: logic.selectIndex == index + ? Colors.white + : Colors.white.withValues(alpha: .5), + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ), + ), + ), + ), + ), + 18.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "生成一次AI小说费用是 ", + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 14, + ), + ), + Text( + '${Config.aiNovelPrice}金币', + style: TextStyle( + color: AppColors.actionRed, + fontSize: 14, + ), + ) + ], + ), + InkWell( + enableFeedback: false, + onTap: () => logic.submit(), + child: Container( + height: 44, + margin: EdgeInsets.symmetric(vertical: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + "立即提交", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + )), + // 0.5.line, + // 10.sizeBoxH, + ], + ).paddingSymmetric(horizontal: 10); + }, + ), + ); + } + + _buildInputView( + TextEditingController textCtr, { + String title = '人物设定(选填)', + String hint = '示例:总裁、公主、小萝莉.', + int maxLength = 20, + }) { + return Column( + children: [ + Row( + children: [ + Text( + title, + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + 10.sizeBoxH, + Container( + margin: EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.all(Radius.circular(5)), + boxShadow: [ + BoxShadow( + color: Colors.white.withValues(alpha: .05), + blurRadius: 1, //阴影模糊程度 + spreadRadius: 1.0, //阴影扩散程度 + ) + ], + ), + child: AIDrawTextField( + controller: textCtr, + hintText: hint, + maxLength: maxLength, + height: 112, + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/ai/ai_record/ai_main_record_logic.dart b/lib/hj_page/ai/ai_record/ai_main_record_logic.dart new file mode 100644 index 0000000..650f6c6 --- /dev/null +++ b/lib/hj_page/ai/ai_record/ai_main_record_logic.dart @@ -0,0 +1,37 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../config/config.dart'; +import '../../../hj_model/splash/domain_source_model.dart'; +import '../ai_sub_type/ai_function_logic.dart'; + +class AIMainRecordLogic extends GetxController with GetTickerProviderStateMixin { + final AiType? aiType; + AIMainRecordLogic({this.aiType}); + + final List menus = []; + late TabController tabCtr; + + @override + void onInit() { + super.onInit(); + // 拷贝一份再过滤/排序,避免原地改动全局 Config.aiTypes 影响其他页面 + final aiTypes = List.from(Config.aiTypes ?? []); + //1.删除未开启的和 AI 女友 + aiTypes.removeWhere((model) => model.isOpen == false || model.aiType == AiType.aiMate); + //2.排序 + aiTypes.sort((m1, m2) => (m1.sort ?? 0).compareTo(m2.sort ?? 0)); + menus.addAll(aiTypes); + //3.定位默认进入的 tab + final initialIndex = max(0, aiTypes.indexWhere((model) => model.aiType == aiType)); + tabCtr = TabController(initialIndex: initialIndex, length: menus.length, vsync: this); + } + + @override + void onClose() { + tabCtr.dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/ai/ai_record/ai_main_record_page.dart b/lib/hj_page/ai/ai_record/ai_main_record_page.dart new file mode 100644 index 0000000..81c3aac --- /dev/null +++ b/lib/hj_page/ai/ai_record/ai_main_record_page.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../../../hj_model/splash/domain_source_model.dart'; +import '../ai_sub_type/ai_function_logic.dart'; +import 'ai_main_record_logic.dart'; +import 'ai_record_page.dart'; + +/// AI 生成记录首页:按已开启的 AI 功能列 Tab,每 Tab 一个对应类型的记录子页 +class AIMainRecordPage extends StatelessWidget { + final AiType? aiType; + const AIMainRecordPage({super.key, this.aiType}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: AIMainRecordLogic(aiType: aiType), + builder: (logic) { + return Scaffold( + appBar: AppBar(title: Text('生成记录')), + body: Column( + children: [ + _buildTabbar(logic), + Expanded( + child: TabBarView( + controller: logic.tabCtr, + children: logic.menus.map((e) => _subPage(e)).toList(), + ), + ), + ], + ), + ); + }, + ); + } + + // 所有 AI 功能统一用 AiRecordPage,内部按 aiType 区分接口/列表/item + Widget _subPage(AISwitchConf model) { + final type = model.aiType; + if (type == null) return Container(); + return AiRecordPage(aiType: type).keepAlive; + } + + Widget _buildTabbar(AIMainRecordLogic logic) { + return Container( + height: 36, + color: Colors.black, + child: TabBar( + tabAlignment: TabAlignment.center, + controller: logic.tabCtr, + tabs: List.generate( + logic.tabCtr.length, + (index) => Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: Text(logic.menus[index].aiTypeName ?? ""), + ), + ), + isScrollable: true, + labelPadding: EdgeInsets.zero, + labelColor: Colors.white.withValues(alpha: .9), + labelStyle: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + unselectedLabelColor: Colors.white.withValues(alpha: .35), + unselectedLabelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + indicator: CustomIndicator( + isGradient: true, + width: 13, + height: 3, + borderRadius: BorderRadius.circular(1.5), + ), + ), + ); + } +} diff --git a/lib/hj_page/ai/ai_record/ai_record_logic.dart b/lib/hj_page/ai/ai_record/ai_record_logic.dart new file mode 100644 index 0000000..475b885 --- /dev/null +++ b/lib/hj_page/ai/ai_record/ai_record_logic.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/list_base_model.dart'; +import 'package:hgdj/hj_utils/api_service/ai_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../ai_sub_type/ai_function_logic.dart'; +import '../models/ai_record_model.dart'; +import '../widgets/ai_image_record.dart'; +import '../widgets/ai_novel_record.dart'; +import '../widgets/ai_paint_record.dart'; +import '../widgets/ai_video_record.dart'; + +/// AI 生成记录 logic:一个类按 aiType 区分业务(接口 / 删除 / item widget) +/// 覆盖脱衣 / 视频换脸 / 图片换脸 / 小说 / 图生视频 / 绘画六类 +class AIRecordLogic extends GetxController { + AIRecordLogic(this.aiType, {this.sort = 1}); + + final AiType aiType; + final int sort; // 1排队 2成功 3失败,按状态固定,由所属状态页构造传入 + final dataSource = []; + int page = 1; + RefreshController? refreshCtr; + bool isLoading = true; + + @override + void onReady() { + super.onReady(); + fetchPageData(); + } + + Future fetchPageData({bool isRefresh = true}) async { + if (isRefresh) page = 1; + try { + final res = await _fetchList(page); + isLoading = false; + if (isRefresh) { + dataSource.clear(); + refreshCtr?.refreshCompleted(); + } + dataSource.addAll(res?.list ?? []); + (res?.hasNext ?? false) + ? refreshCtr?.loadComplete() + : refreshCtr?.loadNoData(); + page += 1; + } catch (e) { + isLoading = false; + refreshCtr?.refreshCompleted(); + refreshCtr?.loadComplete(); + } + update(); + } + + // 按 aiType 调对应记录接口(status 即 sort:1排队 2成功 3失败) + Future?> _fetchList(int page) { + switch (aiType) { + case AiType.videoChangeFace: // 视频换脸:sort(1/2/3) 映射成接口状态码(0/1/-1) + return AIService.getChangeFaceList(page, 10, [0, 1, -1][sort - 1]); + case AiType.imageChangeFace: // 图片换脸 + return AIService.getImgList(page, 10, sort); + case AiType.aiNovel: // 小说 + return AIService.getNovelList(page, 10, sort); + case AiType.imageToVideo: // 图生视频 + return AIService.getImgVideoList(page, 10, sort); + case AiType.aiPaint: // ai绘画(文生图) + return AIService.getTextToImageList(page, 10, sort); + default: // 脱衣 + return AIService.getUndressList(page, 10, sort); + } + } + + Widget instanceChild(int index) { + final model = dataSource[index]; + switch (aiType) { + case AiType.aiNovel: + return AINovelRecord(model, + onDeleteCallback: () => deleteRecord(index)); + case AiType.imageToVideo: + return AIVideoRecord(model, + status: sort, onDeleteCallback: () => deleteRecord(index)); + case AiType.aiPaint: + return AIPaintRecord(model, + status: sort, onDeleteCallback: () => deleteRecord(index)); + default: //脱衣 / 视频换脸 / 图片换脸 + return AIImageRecord(model, + aiType: aiType, onDeleteCallback: () => deleteRecord(index)); + } + } + + Future deleteRecord(int index) async { + if (!await CommonAlert.show(content: '是否删除该生成记录?')) return false; + if (!await _deleteBill(dataSource[index].id)) return false; + dataSource.removeAt(index); + showToast('删除成功'); + update(); + return true; + } + + // 按 aiType 调对应删除接口 + Future _deleteBill(String? id) { + switch (aiType) { + case AiType.videoChangeFace: + return AIService.deleteChangeFace(id); + case AiType.imageChangeFace: + return AIService.deleteImg(id); + case AiType.aiNovel: + return AIService.deleteNovel(id); + case AiType.imageToVideo: + return AIService.deleteImgVideo(id); + case AiType.aiPaint: + return AIService.deleteTextToImage(id); + default: + return AIService.deleteUndress(id); + } + } +} diff --git a/lib/hj_page/ai/ai_record/ai_record_page.dart b/lib/hj_page/ai/ai_record/ai_record_page.dart new file mode 100644 index 0000000..75de013 --- /dev/null +++ b/lib/hj_page/ai/ai_record/ai_record_page.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../ai_sub_type/ai_function_logic.dart'; +import 'ai_record_logic.dart'; + +const _subTypes = ['排队', '成功', '失败']; + +/// 某个 AI 功能的生成记录子页:排队/成功/失败 3 个 keepalive 状态页,可横滑切换 +class AiRecordPage extends StatefulWidget { + final AiType aiType; // 脱衣/视频换脸/图片换脸/小说 + + const AiRecordPage({super.key, required this.aiType}); + + @override + State createState() => _AiRecordPageState(); +} + +class _AiRecordPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabCtr = + TabController(length: _subTypes.length, vsync: this); + + @override + void dispose() { + _tabCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _buildTabBar(), + Expanded( + child: TabBarView( + controller: _tabCtr, + children: List.generate( + _subTypes.length, + (index) => + _AiRecordStatusList(aiType: widget.aiType, status: index + 1) + .keepAlive, + ), + ), + ) + ], + ); + } + + // 排队/成功/失败 状态 Tab + Widget _buildTabBar() { + return TabBar( + controller: _tabCtr, + tabs: List.generate( + _subTypes.length, + (index) => Container( + padding: EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: index != 2 + ? Border( + right: BorderSide( + width: .5, color: Colors.white.withValues(alpha: .1)), + ) + : null, + ), + child: Text(_subTypes[index]), + ), + ), + isScrollable: true, + padding: EdgeInsets.only(top: 10, left: 7), + labelPadding: EdgeInsets.zero, + tabAlignment: TabAlignment.start, + unselectedLabelStyle: + TextStyle(color: Colors.white.withValues(alpha: .35), fontSize: 14), + labelStyle: + TextStyle(color: Colors.white.withValues(alpha: .9), fontSize: 14), + indicator: const BoxDecoration(), + ); + } +} + +/// 单个状态(排队/成功/失败)的记录列表,独立 logic 实例 + 独立分页/刷新 +class _AiRecordStatusList extends StatelessWidget { + final AiType aiType; + final int status; // 1排队 2成功 3失败 + + const _AiRecordStatusList({required this.aiType, required this.status}); + + String get _tag => 'airec_${aiType.name}_$status'; + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: _tag, + init: AIRecordLogic(aiType, sort: status), + builder: (logic) { + return pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onRefresh: (_) => logic.fetchPageData(), + onLoading: (_) => logic.fetchPageData(isRefresh: false), + child: _buildList(logic), + ); + }, + ); + } + + Widget _buildList(AIRecordLogic logic) { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.dataSource.isEmpty) return CErrorWidget(); + return ListView.separated( + padding: EdgeInsets.only(left: 16, right: 16, top: 18), + separatorBuilder: (_, index) => 12.sizeBoxH, + itemCount: logic.dataSource.length, + itemBuilder: (BuildContext context, int index) => + logic.instanceChild(index), + ); + } +} diff --git a/lib/hj_page/ai/ai_square/ai_square_logic.dart b/lib/hj_page/ai/ai_square/ai_square_logic.dart new file mode 100644 index 0000000..4cb1668 --- /dev/null +++ b/lib/hj_page/ai/ai_square/ai_square_logic.dart @@ -0,0 +1,52 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/ai_service.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +import '../ai_home_logic.dart'; +import '../ai_mod_detail_page.dart'; +import '../ai_sub_type/ai_function_logic.dart'; +import '../models/ai_square_model.dart'; + +class AiSquareLogic extends ListBaseLogic { + @override + void onReady() { + super.onReady(); + loadData(); + } + + //isRefresh=true 下拉刷新 / false 上拉加载更多 + void loadData({bool isRefresh = true}) => + fetchData(isRefresh: isRefresh, fetch: _fetch); + + Future<(List?, bool)> _fetch(int page) async { + final resp = await AIService.getPlazaList(page); + return (resp?.list, resp?.hasNext ?? false); + } + + //「做同款」:脱衣/图生视频/绘画直接切到对应 Tab,换脸类先查模版还在不在 + void makeTheSameStyle(AISquareItemModel model) { + final logic = Get.find(); + switch (AiTypeCode.fromServerCode(model.type)) { + case AiType.autoStrip: + logic.jumpSubModule(3); + case AiType.imageToVideo: + logic.jumpSubModule(1); + case AiType.aiPaint: + logic.jumpSubModule(2); + default: + jumpToChangeFace(model); + } + } + + //模版还在就进详情,被下架了退回换脸 Tab + void jumpToChangeFace(AISquareItemModel model) async { + final mod = await AIService.getModelInfo(model.template, model.type); + if (mod != null) { + Get.to(() => AIModDetailPage(mod)); + return; + } + final isImageFace = + AiTypeCode.fromServerCode(model.type) == AiType.imageChangeFace; + Get.find().jumpSubModule(isImageFace ? 5 : 4); + } +} diff --git a/lib/hj_page/ai/ai_square/ai_square_page.dart b/lib/hj_page/ai/ai_square/ai_square_page.dart new file mode 100644 index 0000000..42f1b6d --- /dev/null +++ b/lib/hj_page/ai/ai_square/ai_square_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../widgets/ai_square_cell.dart'; +import 'ai_square_logic.dart'; + +class AiSquarePage extends StatelessWidget { + AiSquarePage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: AiSquareLogic(), + builder: (logic) { + return Scaffold( + body: pullYsRefresh( + onRefresh: (c) => logic.loadData(), + onLoading: (c) => logic.loadData(isRefresh: false), + onInit: (ctr) => logic.refreshCtr = ctr, + child: _buildContent(logic), + ), + ); + }, + ); + } + + Widget _buildContent(AiSquareLogic logic) { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.isEmptyData) { + return CErrorWidget(retryOnTap: () => logic.loadData()); + } + final list = logic.dataList!; + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 12), + separatorBuilder: (context, index) => 18.sizeBoxH, + itemCount: list.length, + itemBuilder: (ctx, index) => AISquareCell( + model: list[index], + onTap: () => logic.makeTheSameStyle(list[index]), + ), + ); + } +} diff --git a/lib/hj_page/ai/ai_sub_type/ai_function_logic.dart b/lib/hj_page/ai/ai_sub_type/ai_function_logic.dart new file mode 100644 index 0000000..cdd9eb2 --- /dev/null +++ b/lib/hj_page/ai/ai_sub_type/ai_function_logic.dart @@ -0,0 +1,208 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/api_service/ai_service.dart'; +import 'package:hgdj/tools_base/file_upload/file_upload_tool.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_alert_widget.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; + +import '../../../alert/mine/vip_level_dialog.dart'; +import '../../../routers/jump_router.dart'; +import '../../mine/mine_vip/coupon_model.dart'; +import '../models/ai_mod_list_model.dart'; +import '../widgets/ai_coupon_sheet.dart'; + +enum AiType { + imageToVideo, //图生视频 + autoStrip, //智能脱衣 + videoChangeFace, //视频换脸 + imageChangeFace, //图片换脸 + aiMate, //ai女友 + aiPaint, //ai绘画 + aiNovel, //ai小说 +} + +/// 与服务端 type 编码互转,只适用于 /aiplaza/list 这套 1..6 +/// 注意:aiSwitchConf 的 type 是另一套编码(1脱衣/2视频换脸/3图片换脸,见 domain_source_model 的 _aiTypeConfMap),别混用 +/// aiMate 走独立接口、后端没给编码,故映射为 null +extension AiTypeCode on AiType { + //换脸模版列表按图片/视频分别取 type,网格列数与宽高比也跟着这个走 + bool get isVideoFace => this == AiType.videoChangeFace; + + static AiType? fromServerCode(int? code) => switch (code) { + 1 => AiType.imageChangeFace, + 2 => AiType.videoChangeFace, + 3 => AiType.autoStrip, + 4 => AiType.imageToVideo, + 5 => AiType.aiPaint, + 6 => AiType.aiNovel, + _ => null, + }; +} + +/// AI 各功能 logic 的公共部分:图片载体 + 计费校验 + 上传下单模板 +abstract class AiFunctionBaseLogic extends GetxController { + AiFunctionBaseLogic(this.modList); + + AiModList? modList; //接口下发的模版数据 + final localPicList = []; //已选本地图片路径,与 PicPicker 共享 + + bool shareToAiSquare = true; + final editingCtr = TextEditingController(); + + //VIP 门槛文案,配合各功能里注释掉的会员校验使用 + final hint = '您还不是VIP无法使用AI脱衣'; + + @override + void onClose() { + editingCtr.dispose(); + super.onClose(); + } + + /// 提交入口,各功能自行实现校验与下单 + Future submit(); + + //免费次数或金币够不够本次消费 + bool canPay(int price) { + final freeCount = (globalStore.wallet?.todayAiFreeTimes ?? 0) + + (presaleProvider.remain?.todayAiUndressCount ?? 0); + if ((globalStore.wallet?.amount ?? 0) < price && freeCount <= 0) { + showVipLevelDialog("当前免费次数不足或金币余额不足", buttonTitle: '去充值', vipEvent: () { + Get.back(); + pushToWalletPage(tabPosition: 1); + }); + return false; + } + return true; + } + + //校验有没有选图,必须在扣免费次数之前调,否则次数会白扣 + bool checkPic() { + if (localPicList.isNotEmpty) return true; + CommonAlert.show(title: "提示", content: "请选择图片", showCancel: false); + return false; + } + + /// 上传 [pics] → [generate] 下单 → 统一处理 loading / toast / 清态 + /// 提交成功后会清空 localPicList,上传途中怕被清就传副本 + Future upload( + List pics, + Future Function(List urls) generate, { + VoidCallback? onDone, + }) async { + await FileUploadTool().uploadImagesWithProgress( + pics, + onFailure: () => showToast("图片上传失败"), + onSuccess: (urls) async { + LoadingAlertWidget.show(title: "正在更新数据..."); + try { + if (await generate(urls)) { + showToast("提交成功~"); + localPicList.clear(); + onDone?.call(); + globalStore.refreshWallet(); + update(); + } else { + showToast("提交失败"); + } + } on DioException catch (e) { + showToast(e.message.toString()); + } catch (e) { + showToast(e.toString()); + } finally { + LoadingAlertWidget.cancel(); + } + }, + ); + } +} + +// 智能脱衣 +class AiStripLogic extends AiFunctionBaseLogic { + AiStripLogic(super.modList); + + double get aspectRatio => 408 / 310; + + @override + void onReady() { + super.onReady(); + loadData(); + } + + //获取脱衣模版 + Future loadData() async { + modList = await AIService.getModelList(); + update(); + } + + @override + Future submit() async { + if (!checkPic()) return; + //预售期有免费次数先走免费,否则查金币 + if (presaleProvider.isOpen && + presaleProvider.hasLimit && + (presaleProvider.remain?.todayAiUndressCount ?? 0) > 0) { + presaleProvider.remain?.todayAiUndressCount = + (presaleProvider.remain?.todayAiUndressCount ?? 0) - 1; + } else { + if (!canPay(int.tryParse(Config.aiUndressPrice) ?? 0)) return; + } + //传副本:提交成功回调里会清空 localPicList + await upload( + [...localPicList], + (urls) => + AIService.generateUndress(urls, shareToAiSquare, editingCtr.text)); + } +} + +// 换脸详情 +class AiFaceDetailLogic extends AiFunctionBaseLogic { + AiFaceDetailLogic(super.modList, {required this.mod}); + + final TemplateModel mod; // 换脸模版 + AICouponModel? coupon; //折扣券 + + bool get isChangeVideo => mod.moduleType == 1; // 是否为视频 + + // 视频/图片换脸都用模版里的价格 + int get price => globalStore.isVIP ? (mod.vipCoin ?? 0) : (mod.coin ?? 0); + + @override + String get hint => '您还不是充值VIP无法使用AI换脸'; + + @override + Future submit() async { + if (!checkPic()) return; + //预售期图片换脸有免费次数先走免费,否则查金币 + if (presaleProvider.isOpen && + presaleProvider.hasLimit && + (presaleProvider.remain?.todayAiUndressCount ?? 0) > 0 && + !isChangeVideo) { + presaleProvider.remain?.todayAiUndressCount = + (presaleProvider.remain?.todayAiUndressCount ?? 0) - 1; + } else { + if (!canPay(price)) return; + } + await upload( + localPicList, + (urls) => isChangeVideo + ? AIService.generateChangeFace( + urls, mod.id, coupon?.id, shareToAiSquare, editingCtr.text) //视频换脸 + : AIService.generateImg(urls.firstOrNull ?? '', mod.id ?? '', + shareToAiSquare, editingCtr.text), //图片换脸 + onDone: () => coupon = null, + ); + } + + Future showCoupon() async { + final AICouponModel? model = await Get.bottomSheet(AICouponSheet()); + if (model != null) { + coupon = model; + update(); + } + } +} diff --git a/lib/hj_page/ai/ai_sub_type/ai_strip_sub_page.dart b/lib/hj_page/ai/ai_sub_type/ai_strip_sub_page.dart new file mode 100644 index 0000000..3553350 --- /dev/null +++ b/lib/hj_page/ai/ai_sub_type/ai_strip_sub_page.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/banner/ads_banner_widget.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:provider/provider.dart'; + +import '../widgets/pic_picker.dart'; +import 'ai_function_logic.dart'; + +//ai脱衣 +class AIStripSubPage extends StatelessWidget { + const AIStripSubPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: AiStripLogic(null), + builder: (logic) { + if (logic.modList == null) return LoadingCenterWidget(); + if (logic.modList!.aiUndressMod?.isEmpty == true) return CErrorWidget(); + return Container( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 12.sizeBoxH, + _uploadHeader(logic), + 18.sizeBoxH, + 0.5.line, + 18.sizeBoxH, + Text( + "案例鉴赏", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + 12.sizeBoxH, + _buildBanner(logic), + 18.sizeBoxH, + 0.5.line, + 18.sizeBoxH, + _priceView(), + 18.sizeBoxH, + _buildBtn(logic), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } + + // 上传图片 + 注意事项 + Widget _uploadHeader(AiStripLogic logic) { + return Row( + children: [ + PicPicker( + width: 110, + height: 110, + picList: logic.localPicList, + ), + 12.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "注意事项:", + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + 4.sizeBoxH, + Text( + '''1、素材仅供AI使用,绝无外泄风险,请放心使用. +2、素材需清晰,小于2MB,上传间隔大于60秒. +3、本功能不支持多人图片 +4、生成失败退回金币,若违规作废. 5、禁止使用未成年图片!''', + style: TextStyle( + color: Color(0xff656565), + fontSize: 10, + ), + ), + ], + ), + ), + ], + ); + } + + // 费用信息:免费次数 + 单价 + Widget _priceView() { + return Consumer(builder: (_, provider, __) { + final total = provider.wallet?.aiUndressFreeTimes ?? 0; + final presaleCount = presaleProvider.remain?.todayAiUndressCount ?? 0; + final todayFreeCount = provider.wallet?.todayAiFreeTimes ?? 0; + //今日免费次数 + final toDaytotal = todayFreeCount + presaleCount; + return Column( + children: [ + Text( + '你当前免费体验为$total次数', + style: TextStyle( + color: Color(0xff989898), + fontSize: 12, + ), + ), + 12.sizeBoxH, + Text( + '当日免费$toDaytotal次数', + style: TextStyle( + color: Color(0xff989898), + fontSize: 12, + ), + ), + 12.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "处理一张照片的费用是 ", + style: TextStyle( + color: Color(0xff989898), + fontSize: 12, + ), + ), + Text( + "${Config.aiUndressPrice}金币", + style: TextStyle( + color: AppColors.actionRed, + fontSize: 12, + ), + ), + ], + ), + ], + ); + }); + } + + // 案例轮播 + Widget _buildBanner(AiStripLogic logic) { + return AspectRatio( + aspectRatio: logic.aspectRatio, + child: AdsBannerWidget( + logic.modList?.aiUndressMod ?? [], + isIndicatorUnderCenter: true, + color: AppColors.actionRed.withValues(alpha: .3), + selectColor: AppColors.actionRed, + isCircle: false, + onItemClick: (index) { + final ad = (logic.modList!.aiUndressMod ?? [])[index]; + pushToPageByLink(ad.href); + }, + ), + ); + } + + Widget _buildBtn(AiStripLogic logic) { + return GestureDetector( + onTap: () => logic.submit(), + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + "立即提交", + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/ai/models/ai_change_face_video_model.dart b/lib/hj_page/ai/models/ai_change_face_video_model.dart new file mode 100644 index 0000000..fef24c5 --- /dev/null +++ b/lib/hj_page/ai/models/ai_change_face_video_model.dart @@ -0,0 +1,35 @@ +//AI 模版项:脱衣/换脸/绘画列表共用 +class AiChangeFaceVideoMod { + String? id; + String? title; + String? sourceURL; + int? status; + int? playTime; + String? cover; + String? type; + int? moduleType; + int? hotValue; + String? hotMark; + int? coin; + int? vipCoin; + String? newUrl; // 图生视频结果 + int? styleType; // ai绘画 + + AiChangeFaceVideoMod.fromJson(dynamic json) { + json ??= {}; + id = json['id']; + title = json['title']; + sourceURL = json['sourceURL']; + status = json['status']; + playTime = json['playTime']; + cover = json['cover']; + type = json['type']; + moduleType = json['moduleType']; + hotValue = json['hotValue']; + hotMark = json['hotMark']; + coin = json['coin']; + vipCoin = json['vipCoin']; + newUrl = json['newUrl']; + styleType = json['styleType']; + } +} diff --git a/lib/hj_page/ai/models/ai_girl_resp_model.dart b/lib/hj_page/ai/models/ai_girl_resp_model.dart new file mode 100644 index 0000000..b5a3695 --- /dev/null +++ b/lib/hj_page/ai/models/ai_girl_resp_model.dart @@ -0,0 +1,48 @@ +//ai女友余额 +class AIGirlFriendBalanceModel { + num? balance; + + AIGirlFriendBalanceModel.fromJson(dynamic json) { + json ??= {}; + balance = json['balance']; + } +} + +//ai跳转url +class AIGirlFriendUrlModel { + String? url; + + AIGirlFriendUrlModel.fromJson(dynamic json) { + json ??= {}; + url = json['url'] ?? json['authUrl']; + } +} + +//ai女友货币档位列表 +class AIGirlFriendCurrencys { + List? list; + + AIGirlFriendCurrencys.fromJson(dynamic json) { + json ??= {}; + list = (json['list'] as List?)?.map((e) => AIGirlFriendCurrency.fromJson(e)).toList(); + } +} + +class AIGirlFriendCurrency { + String? id; //货币id + String? name; //货币名称 + num? coins; //购买货币数(对应积分数量) + num? price; //价格(对应需要支付的金币数量), + String? couponDesc; //优惠描述 + int? type; + + AIGirlFriendCurrency.fromJson(dynamic json) { + json ??= {}; + id = json['id']; + name = json['name']; + coins = json['coins']; + price = json['price']; + couponDesc = json['couponDesc']; + type = json['type']; + } +} diff --git a/lib/hj_page/ai/models/ai_mod_list_model.dart b/lib/hj_page/ai/models/ai_mod_list_model.dart new file mode 100644 index 0000000..e9b09c1 --- /dev/null +++ b/lib/hj_page/ai/models/ai_mod_list_model.dart @@ -0,0 +1,107 @@ +import 'package:hgdj/hj_utils/text_util.dart'; + +import '../../../hj_model/splash/ads_model.dart'; +import 'ai_change_face_video_model.dart'; + +//ai脱衣模版 +class AiModList { + /// 脱衣模版 + List? aiUndressMod; + + /// 图片转视频模版 + List? aiImgToVideoMod; + + //ai绘画模版 + List? aiTextToImgMod; + + AiModList(); + + AiModList.fromJson(dynamic json) { + json ??= {}; + aiUndressMod = (json['aiUndressMod'] as List?) + ?.map((e) => AdsInfoModel.fromJson(e)) + .toList(); + aiImgToVideoMod = (json['aiImgToVideoMod'] as List?) + ?.map((e) => AdsInfoModel.fromJson(e)) + .toList(); + aiTextToImgMod = (json['aiTextToImgMod'] as List?) + ?.map((e) => AiChangeFaceVideoMod.fromJson(e)) + .toList(); + } +} + +//ai换脸模版 +class AiChangeModList { + String? categoryId; + List? categoryList; + List? templateList; + + AiChangeModList.fromJson(dynamic json) { + json ??= {}; + categoryId = json['categoryId']; + categoryList = (json['categoryList'] as List?) + ?.map((e) => AICategoryMod.fromJson(e)) + .toList(); + templateList = (json['templateList'] as List?) + ?.map((e) => TemplateModel.fromJson(e)) + .toList(); + } +} + +class AICategoryMod { + int? appId; + String? createdAt; + String? id; + String? name; + int? sortCode; + int? status; + List? templateIds; + int? type; + String? updatedAt; + + AICategoryMod.fromJson(dynamic json) { + json ??= {}; + appId = json['appId']; + createdAt = json['createdAt']; + id = json['id']; + name = json['name']; + sortCode = json['sortCode']; + status = json['status']; + templateIds = parseStringList(json['templateIds']); + type = json['type']; + updatedAt = json['updatedAt']; + } +} + +class TemplateModel { + String? categoryId; //分类id + int? coin; //价格(金豆) + String? cover; //封面 + String? createdAt; //创建时间 + String? id; + int? moduleType; //换脸模版类型 0 图片 1 视频 + String? mp4Url; //视频mp4地址 + String? m3u8Url; + String? title; + int? usedCount; //模版使用次数 + int? vipCoin; + + /// 上架时间戳,模版列表按它排序;时间解析不了按 0 排最前 + int get timestamp => + DateTime.tryParse(createdAt ?? '')?.millisecondsSinceEpoch ?? 0; + + TemplateModel.fromJson(dynamic json) { + json ??= {}; + categoryId = json['categoryId']; + coin = json['coin']; + cover = json['cover']; + createdAt = json['createdAt']; + id = json['id']; + m3u8Url = json['m3u8Url']; + moduleType = json['moduleType']; + mp4Url = json['mp4Url']; + title = json['title']; + usedCount = json['usedCount']; + vipCoin = json['vipCoin']; + } +} diff --git a/lib/hj_page/ai/models/ai_record_model.dart b/lib/hj_page/ai/models/ai_record_model.dart new file mode 100644 index 0000000..b871422 --- /dev/null +++ b/lib/hj_page/ai/models/ai_record_model.dart @@ -0,0 +1,77 @@ +/* +ai 视频换脸的数据注释: +modCover 模版图片 +modMp4Url 模版视频 +picture换脸图片(用户提交的) +cover换脸后封面大图 +url换脸后视频地址 +*/ + +import 'package:hgdj/hj_utils/text_util.dart'; + +import '../../../config/address.dart'; + +class AiRecordModel { + String? id; + int? uid; + String? originPic; + String? imgUrl; + String? newImgUrl; + String? styleUrl; + String? text; + List? originPics; + List? newPic; + List? picture; + int? coin; + int? status; + String? remark; + String? url; + String? updateAct; + String? createdAt; + String? updatedAt; + String? vidId; + String? modPic; + String? modCover; + String? modMp4Url; + String? cover; + String? content; + String? characterSetting; //小说人物设定/故事背景等 + String? description; //故事情节描述 + String? details; //细节说明/其他要求 + String? locationScene; //地点场景 + + /// 换脸结果视频地址。query 用 cdn= 而不是 VideoModel 的 c=,两边接口不同,别顺手统一 + String get realVideoUrl => + "${Address.baseApiPath}/vid/h5/m3u8/$url?token=${Address.token}&cdn=${Address.cdnAddress}"; + + AiRecordModel.fromJson(dynamic json) { + json ??= {}; + id = json['id']; + uid = json['uid']; + originPic = json['originPic']; + newImgUrl = json['newImgUrl']; + imgUrl = json['imgUrl']; + styleUrl = json['styleUrl']; + text = json['text']; + originPics = parseStringList(json['originPics']); + picture = parseStringList(json['picture']); + newPic = parseStringList(json['newPic']); + coin = json['coin']; + status = json['status']; + remark = json['remark']; + url = json['url']; + updateAct = json['updateAct']; + createdAt = json['createdAt']; + updatedAt = json['updatedAt']; + vidId = json['vidId']; + modPic = json['modPic']; + modCover = json['modCover']; + modMp4Url = json['modMp4Url']; + cover = json['cover']; + content = json['content']; + characterSetting = json['characterSetting']; + description = json['description']; + details = json['details']; + locationScene = json['locationScene']; + } +} diff --git a/lib/hj_page/ai/models/ai_square_model.dart b/lib/hj_page/ai/models/ai_square_model.dart new file mode 100644 index 0000000..1d21d2f --- /dev/null +++ b/lib/hj_page/ai/models/ai_square_model.dart @@ -0,0 +1,64 @@ +import '../../../hj_model/video_model.dart'; + +class AISquareItemModel { + String? createdAt; + String? gender; + String? generateImage; + String? generateVideo; + String? generateVideoCover; + String? id; + String? name; + String? originContent; + String? originalImage; + String? originalVideo; + String? originalVideoCover; + String? portrait; + String? reason; + String? reviewAt; + int? sortCode; + int? status; + String? template; + String? title; + + /// 1-ai图片换脸 2-ai视频换脸 3-ai脱衣 4-ai图生视频 5-ai绘画 + int? type; + int? uid; + String? updatedAt; + + /// 生成结果视频的播放地址,借 VideoModel 拼(拼接规则只在那一处维护) + String get realGenerateVideoUrl => (VideoModel()..sourceURL = generateVideo).realVideoUrl; + + String get typeString => switch (type) { + 1 => '图片换脸', + 2 => '视频换脸', + 3 => 'AI脱衣', + 4 => '图生视频', + 5 => 'AI绘画', + _ => '', + }; + + AISquareItemModel.fromJson(dynamic json) { + json ??= {}; + createdAt = json['createdAt']; + gender = json['gender']; + generateImage = json['generateImage']; + generateVideo = json['generateVideo']; + generateVideoCover = json['generateVideoCover']; + id = json['id']; + name = json['name']; + originContent = json['originContent']; + originalImage = json['originalImage']; + originalVideo = json['originalVideo']; + originalVideoCover = json['originalVideoCover']; + portrait = json['portrait']; + reason = json['reason']; + reviewAt = json['reviewAt']; + sortCode = json['sortCode']; + status = json['status']; + template = json['template']; + title = json['title']; + type = json['type']; + uid = json['uid']; + updatedAt = json['updatedAt']; + } +} diff --git a/lib/hj_page/ai/widgets/ai_coupon_sheet.dart b/lib/hj_page/ai/widgets/ai_coupon_sheet.dart new file mode 100644 index 0000000..f568918 --- /dev/null +++ b/lib/hj_page/ai/widgets/ai_coupon_sheet.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../hj_utils/widget_util.dart'; +import '../../mine/mine_vip/coupon_model.dart'; + +/// 抵扣券选择弹窗:选中一张后 [Get.back] 回传 [AICouponModel] +class AICouponSheet extends StatefulWidget { + const AICouponSheet({super.key}); + + @override + State createState() => _AICouponSheetState(); +} + +class _AICouponSheetState extends State { + int curPage = 1; + List? dataList; //null = 还在首屏加载 + RefreshController? refreshCtr; + + @override + void initState() { + super.initState(); + loadData(); + } + + Future loadData({int page = 1}) async { + const size = 10; //请求条数和「还有没有下一页」的判断必须用同一个值 + final res = await MineService.backPack(page, limit: size); + if (res != null) { + if (page == 1) dataList = []; //刷新成功才清空,失败保留旧数据 + (dataList ??= []).addAll(res); + curPage = page; + } + dataList ??= []; //首屏失败也要退出 loading,否则永远转圈 + setState(() {}); + if (page == 1) refreshCtr?.refreshCompleted(); + (res?.length ?? 0) < size + ? refreshCtr?.loadNoData() + : refreshCtr?.loadComplete(); + } + + Future loadMore() => loadData(page: curPage + 1); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + decoration: const BoxDecoration( + color: AppColors.primaryColor, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + const SheetHandleBar(), + 18.sizeBoxH, + Text('使用抵扣券', + style: textStyle( + 18, Colors.white.withValues(alpha: .9), FontWeight.w600)), + 18.sizeBoxH, + Expanded(child: _list()), + ], + ), + ); + } + + Widget _list() { + if (dataList == null) return LoadingCenterWidget(); + if (dataList!.isEmpty) return CErrorWidget(retryOnTap: loadData); + return pullYsRefresh( + onInit: (ctr) => refreshCtr = ctr, + onRefresh: (_) => loadData(), + onLoading: (_) => loadMore(), + child: ListView.separated( + itemCount: dataList!.length, + separatorBuilder: (_, __) => 12.sizeBoxH, + itemBuilder: (_, index) => _couponItem(dataList![index]), + ), + ); + } + + Widget _couponItem(AICouponModel model) { + //券面是暖色底图,三处文字统一用这个深棕 + const couponText = Color(0xff7E4444); + return InkWell( + enableFeedback: false, + onTap: () => Get.back(result: model), + child: Container( + alignment: Alignment.center, + height: 68, + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage('ai_coupon_bg.webp'.aiPath), fit: BoxFit.fill), + ), + child: Row( + children: [ + 18.sizeBoxW, + Text('${model.goodsName}', + style: textStyle(18, couponText, FontWeight.w600)), + 12.sizeBoxW, + Expanded( + child: Text( + '${model.goodsDesc}', + style: textStyle(14, couponText, FontWeight.w400), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + 12.sizeBoxW, + Container( + height: 26, + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xffFFDCB3), + borderRadius: BorderRadius.circular(22), + ), + child: Text('立即使用', + style: textStyle(12, couponText, FontWeight.w400)), + ), + 18.sizeBoxW, + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/ai/widgets/ai_draw_text_field.dart b/lib/hj_page/ai/widgets/ai_draw_text_field.dart new file mode 100644 index 0000000..1e6d844 --- /dev/null +++ b/lib/hj_page/ai/widgets/ai_draw_text_field.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +/// AI 输入框:多行 + 右下角字数统计。 +/// controller 归外部 logic 持有,本 widget 只摘自己挂的监听 +class AIDrawTextField extends StatefulWidget { + final TextEditingController controller; + final String hintText; + final int maxLength; + final double height; + + const AIDrawTextField({ + super.key, + required this.controller, + this.hintText = '主人来两句嘛~', + this.maxLength = 500, + this.height = 181, + }); + + @override + State createState() => _AIDrawTextFieldState(); +} + +class _AIDrawTextFieldState extends State { + @override + void initState() { + super.initState(); + widget.controller.addListener(_onTextChanged); + } + + @override + void dispose() { + widget.controller.removeListener(_onTextChanged); + super.dispose(); + } + + //刷新底部字数统计 + void _onTextChanged() => setState(() {}); + + @override + Widget build(BuildContext context) { + return Container( + height: widget.height, + padding: const EdgeInsets.only(left: 10, right: 6), + child: Column( + children: [ + Expanded( + child: TextField( + controller: widget.controller, + style: TextStyle(color: Colors.white.withValues(alpha: .8), fontSize: 12), + maxLength: widget.maxLength, + maxLines: null, + decoration: InputDecoration( + hintText: widget.hintText, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2), + counterText: '', //自己在下面画统计,藏掉系统那个 + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: .6), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + hintMaxLines: 10, + ), + ), + ), + Container( + padding: const EdgeInsets.only(bottom: 10), + alignment: Alignment.centerRight, + child: Text( + '${widget.controller.text.length}/${widget.maxLength}', + style: TextStyle(color: Colors.white.withValues(alpha: .5), fontSize: 12), + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/ai/widgets/ai_girl_sheet.dart b/lib/hj_page/ai/widgets/ai_girl_sheet.dart new file mode 100644 index 0000000..2154879 --- /dev/null +++ b/lib/hj_page/ai/widgets/ai_girl_sheet.dart @@ -0,0 +1,215 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/api_service/ai_service.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/loading/loading_alert_widget.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; + +import '../../../hj_utils/widget_util.dart'; +import '../models/ai_girl_resp_model.dart'; + +/// AI 女友充值积分弹窗 +class AIGirlSheet extends StatefulWidget { + final List? list; + + const AIGirlSheet({super.key, this.list}); + + @override + State createState() => _AIGirlSheetState(); +} + +class _AIGirlSheetState extends State { + int selectIndex = 0; + + //TapGestureRecognizer 必须自己释放,建一次复用,别在 build 里 new + final _serviceTap = TapGestureRecognizer()..onTap = pushToCustomService; + + List get _items => widget.list ?? []; + + //列表可能为空,取不到就没得选 + AIGirlFriendCurrency? get _selected => + selectIndex < _items.length ? _items[selectIndex] : null; + + @override + void dispose() { + _serviceTap.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Container( + decoration: const BoxDecoration( + color: Color(0xff1D2236), + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 18), + const SheetHandleBar(color: Color(0x1AFFFFFF)), + Padding( + padding: const EdgeInsets.only(top: 18, bottom: 12), + child: Text('AI女友', + style: textStyle(20, Colors.white, FontWeight.w600)), + ), + Text('充值积分', + style: textStyle( + 14, Colors.white.withValues(alpha: .8), FontWeight.w400)), + GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 106 / 60, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: _items.length, + itemBuilder: (_, index) => _item(index), + ), + //小贴士整块左右各留 28 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('支付小贴士:', + style: textStyle(14, Colors.white, FontWeight.w500)), + const SizedBox(height: 6), + Text( + '1.因超时支付无法到账,请重新发起。\n2.连续发起且未支付,账号可能被加入黑名单\n3.充值成功后会在1~5分钟内到账,可重新刷新进入当前页面', + style: textStyle(12, Colors.white.withValues(alpha: .6), + FontWeight.w400), + ), + ], + ), + ), + const SizedBox(height: 18), + GestureDetector( + onTap: _onPay, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xff1E3C72), + Color(0xff5F40B6), + Color(0xff1E3C72), + Color(0xff2A5298) + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + borderRadius: BorderRadius.circular(22), + ), + child: Text( + '${(_selected?.price ?? 0).truncate()}/立即支付', + style: textStyle(16, Colors.white, FontWeight.w400), + ), + ), + ), + const SizedBox(height: 18), + EasyRichText( + '支付中如有问题 请联系在线客服', + textAlign: TextAlign.center, + defaultStyle: textStyle( + 12, Colors.white.withValues(alpha: .6), FontWeight.w400), + patternList: [ + EasyRichTextPattern( + targetString: '在线客服', + matchOption: 'first', + style: const TextStyle(color: AppColors.primaryHighColor), + recognizer: _serviceTap, + ), + ], + ), + const SizedBox(height: 30), + ], + ), + ), + ); + } + + Widget _item(int index) { + final item = _items[index]; + final isSelected = selectIndex == index; + return GestureDetector( + onTap: () => setState(() => selectIndex = index), + child: Container( + decoration: BoxDecoration( + color: isSelected + ? const Color(0xff4039A1) + : Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isSelected + ? const Color(0xff30509C) + : Colors.white.withValues(alpha: .1), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${item.coins ?? 0}积分', + style: textStyle( + 16, + isSelected + ? const Color(0xffAFABFF) + : const Color(0xff7D95D5), + FontWeight.w400), + ), + const SizedBox(height: 4), + Text( + '${(item.price ?? 0).truncate()}金币', + style: textStyle( + 12, + isSelected + ? const Color(0x4DF3F5FF) + : Colors.white.withValues(alpha: .3), + FontWeight.w400, + ), + ), + ], + ), + ), + ); + } + + Future _onPay() async { + final item = _selected; + if (item == null) return; + try { + LoadingAlertWidget.show(); + final value = await AIService.exchangeMate({'id': item.id ?? ''}); + LoadingAlertWidget.cancel(); + if (value is String) { + showToast(value); + return; + } + if (value.code == 200) { + showToast('充值成功'); + Get.back(); + return; + } + if (value.code == 8000) { + //金币不足 + showToast('金币不足,请充值'); + pushToWalletPage(tabPosition: 1); + } + } catch (e) { + LoadingAlertWidget.cancel(); + debugLog(e); + } + } +} diff --git a/lib/hj_page/ai/widgets/ai_image_record.dart b/lib/hj_page/ai/widgets/ai_image_record.dart new file mode 100644 index 0000000..aeb9042 --- /dev/null +++ b/lib/hj_page/ai/widgets/ai_image_record.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/video_download/video_cache_store.dart'; +import 'package:hgdj/tools_base/video_download/video_download_manager.dart'; +import 'package:hgdj/tools_base/widget/image_browser_page.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../video/simple_video_player_page.dart'; +import '../ai_sub_type/ai_function_logic.dart'; +import '../models/ai_record_model.dart'; +import 'ai_record_parts.dart'; + +// StatusGenning AiChangeFaceStatus = 0 // 未完成 +// StatusComplete AiChangeFaceStatus = 1 // 已完成 +// StatusRefund AiChangeFaceStatus = -1 // 已退款 +// StatusSubmit AiChangeFaceStatus = 2 // 已提交 + +// ai图片换脸 +// Processing = 1 // 1、进行中 +// SUCCESS = 2 // 2、成功 +// FAILURE = 3 // 3、失败 +// REFUND = 4 // 4、退款 +// StatusSubmit = 5 //5,提交 + +// ai脱衣 +// Processing = 1 // 1、进行中 +// SUCCESS = 2 // 2、成功 +// FAILURE = 3 // 3、失败 +// REFUND = 4 // 4、退款 +// PartSuccess = 5 // 5、部分成功 +// SubmitOrder = 6 // 6、已提交 + +/// 脱衣 / 视频换脸 / 图片换脸 三类记录,版式都是「(模版 +) 素材 = 结果」 +class AIImageRecord extends StatelessWidget { + final AiRecordModel model; + final AiType aiType; + final VoidCallback? onDeleteCallback; + + const AIImageRecord(this.model, + {super.key, this.aiType = AiType.autoStrip, this.onDeleteCallback}); + + bool get isVideoFace => aiType.isVideoFace; + + bool get isImageFace => aiType == AiType.imageChangeFace; + + //图片/视频换脸才有模版 + bool get hasTemplate => isVideoFace || isImageFace; + + /// 各业务的 status 编码不同(见文件顶部注释),统一映射成三态,没列到的一律按生成中处理 + AIRecordState get _state { + if (isVideoFace) { + return switch (model.status) { + 1 => AIRecordState.success, + -1 => AIRecordState.failed, + _ => AIRecordState.queuing, + }; + } + if (isImageFace) { + return switch (model.status) { + 2 => AIRecordState.success, + 3 || 4 => AIRecordState.failed, + _ => AIRecordState.queuing, + }; + } + //脱衣,以及其余走这套 UI 的类型 + return switch (model.status) { + 2 => AIRecordState.success, + 3 || 4 || 5 => AIRecordState.failed, + _ => AIRecordState.queuing, + }; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _itemClickHandler, + child: AIRecordCard( + state: _state, + time: model.createdAt, + remark: model.remark, + onDownload: _saveHandler, + onDelete: () => onDeleteCallback?.call(), + child: _buildItemCell(), + ), + ); + } + + //失败态的「失败原因」弹窗由 AIRecordCard 统一处理,这里只管成功态 + void _itemClickHandler() { + if (_state != AIRecordState.success) return; + if (isVideoFace) { + Get.to(SimpleVideoPlayerPage(videoUrl: model.realVideoUrl, title: '')); + return; + } + final images = model.newPic ?? []; + if (images.isEmpty) return; + ImageBrowserPage.open([images.first], showSaveButton: true); + } + + //素材图:视频换脸取 picture、图片换脸只有 originPic、其余取 originPics + String get _sourceCover { + if (isVideoFace) return model.picture?.firstOrNull ?? model.originPic ?? ''; + if (isImageFace) return model.originPic ?? ''; + return model.originPics?.firstOrNull ?? model.originPic ?? ''; + } + + //生成中/失败时结果位回退展示素材封面,成功才是真正结果图 + String get _resultCover { + if (_state == AIRecordState.success) { + //视频换脸有的成功订单后端不回 cover,退回素材封面,别让结果位空着露占位图 + if (isVideoFace) + return model.cover?.isNotEmpty == true ? model.cover! : _sourceCover; + return model.newPic?.firstOrNull ?? ''; + } + if (isVideoFace) return model.picture?.firstOrNull ?? ''; + return model.originPics?.firstOrNull ?? model.originPic ?? ''; + } + + Future _saveHandler() async { + if (!hasTemplate) { + final images = model.newPic ?? []; + if (images.isEmpty) return; + ImageBrowserPage.open([images.first], showSaveButton: true); + return; + } + final isCached = await VideoCacheStore.instance + .isExistLoadVideoByUrl(MediaStyle.Video, model.url ?? ''); + if (isCached) { + showToast('你已经添加过缓存了'); + return; + } + if (!await _checkPermission()) return; + final result = + await VideoDownloadManager.instance.download(url: model.url ?? ''); + if (result != null) return; + final viewModel = VideoModel() + ..sourceURL = model.url + ..title = 'AI换脸视频' + ..coverThumb = model.newPic?.firstOrNull + ..cover = model.newPic?.firstOrNull + ..commentCount = 0 + ..id = '-1'; + showToast('已加入缓存'); + await VideoCacheStore.instance.saveVideoInfo(MediaStyle.Video, viewModel); + } + + Future _checkPermission() async { + var status = await Permission.storage.status; + if (!status.isGranted) status = await Permission.storage.request(); + return status.isGranted; + } + + //视频换脸的结果位单独接播放器,链接由 VideoModel 拼(和 AiRecordModel.realVideoUrl 的 query 不一样) + void _playResultVideo() { + final videoModel = VideoModel()..sourceURL = model.url; + Get.to(SimpleVideoPlayerPage( + videoUrl: videoModel.realVideoUrl, title: 'AI生成视频')); + } + + //三张图:模版 + 素材 = 结果 + Widget _buildItemCell() { + return SizedBox( + height: 90, + child: Row( + children: [ + if (hasTemplate) ...[ + AIRecordThumb( + imageUrl: isImageFace ? model.modPic : model.modCover, + badge: const AISourceBadge(), + width: 90.w, + ), + const AIRecordJoin(gap: 3.5), + ], + AIRecordThumb( + imageUrl: _sourceCover, + badge: const AISourceBadge(isTemplate: false), + width: 90.w), + const AIRecordJoin(isEquals: true, gap: 3.5), + AIResultThumb( + imageUrl: _resultCover, + state: _state, + onTap: isVideoFace ? _playResultVideo : null, + width: 90.w, + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/ai/widgets/ai_mod_cell.dart b/lib/hj_page/ai/widgets/ai_mod_cell.dart new file mode 100644 index 0000000..35f345d --- /dev/null +++ b/lib/hj_page/ai/widgets/ai_mod_cell.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../models/ai_change_face_video_model.dart'; + +/// 换脸模版选择项:封面 + 右上角选中标 + 标题 +class AIModCell extends StatelessWidget { + final AiChangeFaceVideoMod mod; + final bool isSelected; + final VoidCallback? onTap; + + const AIModCell( + {super.key, required this.mod, this.isSelected = false, this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: onTap, + child: Column( + children: [ + AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + NetworkImageLoader(imageUrl: mod.cover ?? ''), + Positioned( + right: 8, + top: 8, + child: Image.asset( + isSelected + ? 'radio_sel.png'.commonImgPath + : 'ai_draw_unselected.png'.aiPath, + width: 20, + ), + ), + ], + ), + ), + const Spacer(), + Text( + mod.title ?? '', + style: TextStyle( + color: Colors.white.withValues(alpha: .8), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/ai/widgets/ai_novel_record.dart b/lib/hj_page/ai/widgets/ai_novel_record.dart new file mode 100644 index 0000000..b97a9ec --- /dev/null +++ b/lib/hj_page/ai/widgets/ai_novel_record.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../hj_utils/widget_util.dart'; +import '../ai_novel/ai_novel_detail_page.dart'; +import '../models/ai_record_model.dart'; +import 'ai_record_parts.dart'; + +/// AI 小说记录:内容是提示词摘要,成功后点进详情看正文 +class AINovelRecord extends StatelessWidget { + final AiRecordModel model; + final VoidCallback? onDeleteCallback; + + const AINovelRecord(this.model, {super.key, this.onDeleteCallback}); + + /// 小说接口的 status → 三态,没列到的一律按生成中处理 + AIRecordState get _state => switch (model.status) { + 3 => AIRecordState.success, + -1 || 4 || 5 => AIRecordState.failed, + _ => AIRecordState.queuing, + }; + + @override + Widget build(BuildContext context) { + return AIRecordCard( + state: _state, + time: model.createdAt, + remark: model.remark, + onDelete: () => onDeleteCallback?.call(), + child: _buildItemCell(), + ); + } + + Widget _buildItemCell() { + return InkWell( + enableFeedback: false, + //只有成功才拦截点击,失败要放行给卡片弹失败原因 + onTap: _state == AIRecordState.success ? () => Get.to(() => AiNovelDetailPage(model)) : null, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 11), + child: Text( + '人物设定:${model.characterSetting}\n地点场景:${model.locationScene}\n故事情节:${model.description}\n细节说明:${model.details}', + style: textStyle(12, const Color(0xff999999), FontWeight.w400), + maxLines: 18, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } +} diff --git a/lib/hj_page/ai/widgets/ai_paint_record.dart b/lib/hj_page/ai/widgets/ai_paint_record.dart new file mode 100644 index 0000000..e726891 --- /dev/null +++ b/lib/hj_page/ai/widgets/ai_paint_record.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/image_browser_page.dart'; + +import '../models/ai_record_model.dart'; +import 'ai_record_parts.dart'; + +/// 文生图(ai绘画) 单条记录(提示词 → 结果图),列表/分页/删除由 AIRecordLogic 统一管理 +class AIPaintRecord extends StatelessWidget { + final AiRecordModel model; + final int status; // 1 排队 2 成功 3 失败 + final VoidCallback? onDeleteCallback; + + const AIPaintRecord(this.model, + {super.key, required this.status, this.onDeleteCallback}); + + AIRecordState get _state => switch (status) { + 2 => AIRecordState.success, + 3 => AIRecordState.failed, + _ => AIRecordState.queuing, + }; + + /// 排队/失败没有结果图,退回模版封面占位。 + /// 后端在这个接口里放模版封面的字段不固定(styleUrl 常为空),挨个兜一遍,别露占位图 + String get _templateCover => + [model.styleUrl, model.cover, model.modCover, model.modPic] + .firstWhere((e) => e?.isNotEmpty == true, orElse: () => '') ?? + ''; + + //只有成功才是真结果图;未知 status 按排队处理,与 _state 的口径保持一致 + String get _resultCover => + status == 2 ? (model.newImgUrl ?? '') : _templateCover; + + @override + Widget build(BuildContext context) { + return AIRecordCard( + state: _state, + time: model.createdAt, + remark: model.remark, + onDownload: _showNewPicture, + onStatusTap: _showNewPicture, + onDelete: () => onDeleteCallback?.call(), + child: _buildItemCell(), + ); + } + + void _showNewPicture() { + final url = model.newImgUrl ?? ''; + if (url.isEmpty) { + showToast('图片地址为空'); + return; + } + ImageBrowserPage.open([url], showSaveButton: true); + } + + Widget _buildItemCell() { + return SizedBox( + height: 90, + child: Row( + children: [ + Container( + width: 181, + height: 58, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + model.text ?? '', + style: const TextStyle(color: Color(0xff999999), fontSize: 12), + ), + ), + const AIRecordJoin(isEquals: true), + AIResultThumb( + imageUrl: _resultCover, + state: _state, + onTap: _resultCover.isEmpty + ? null + : () => + ImageBrowserPage.open([_resultCover], showSaveButton: true), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/ai/widgets/ai_record_parts.dart b/lib/hj_page/ai/widgets/ai_record_parts.dart new file mode 100644 index 0000000..f2db4d8 --- /dev/null +++ b/lib/hj_page/ai/widgets/ai_record_parts.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../hj_utils/widget_util.dart'; + +/// AI 生成记录卡片的公共小件 + +/// 记录状态。接口的 status 各业务口径不同,由各 item 自己映射进来 +enum AIRecordState { queuing, success, failed } + +/// 失败原因:接口没给就退回通用文案。底部文案和失败弹窗共用,改一处即可 +String _failedText(String? remark) => + remark?.isNotEmpty == true ? remark! : '生成失败'; + +/// 记录卡:白 5% 底 + 12 圆角,版式固定为 +/// 创建时间/删除 → 内容 → 细线 → 状态胶囊/下载或失败原因。 +/// 四类记录(脱衣换脸 / 小说 / 图生视频 / 文生图)只有中间的 [child] 不一样, +/// 头尾要调版式只改这里一处 +class AIRecordCard extends StatelessWidget { + final AIRecordState state; + final Widget child; + final String? time; + final String? remark; + + /// 成功时右下角的「一键下载」,不传就不显示(小说记录没有下载) + final VoidCallback? onDownload; + + final VoidCallback? onDelete; + + /// 成功态状态胶囊的点击,不传胶囊就不可点 + final VoidCallback? onStatusTap; + + const AIRecordCard({ + super.key, + required this.state, + required this.child, + this.time, + this.remark, + this.onDownload, + this.onDelete, + this.onStatusTap, + }); + + @override + Widget build(BuildContext context) { + final card = Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 18), + child: Column( + children: [ + AIRecordHeader(time: time, onTap: onDelete), + 12.sizeBoxH, + child, + 12.sizeBoxH, + 0.5.line, + 12.sizeBoxH, + AIRecordFooter( + state: state, + remark: remark, + onDownload: onDownload, + onStatusTap: onStatusTap), + ], + ), + ); + if (state != AIRecordState.failed) return card; + //失败原因底部只放得下两行,点卡片看全文。四类记录都走这里,别再各自实现 + return GestureDetector( + behavior: HitTestBehavior.opaque, //卡片内的空白也要能点 + onTap: () => CommonAlert.show( + title: '失败原因', content: _failedText(remark), showCancel: false), + child: card, + ); + } +} + +/// 卡片头部:左边创建时间,右边删除图标 +class AIRecordHeader extends StatelessWidget { + final String? time; + final VoidCallback? onTap; + + const AIRecordHeader({super.key, this.time, this.onTap}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + '创建时间:${DateTimeUtil.utc2iso(time)}', + style: const TextStyle(fontSize: 12, color: Color(0xffDCDCDC)), + ), + ), + InkWell( + enableFeedback: false, + onTap: onTap, + child: Image.asset('ai_record_delete.png'.aiPath, height: 12), + ), + ], + ); + } +} + +/// 卡片底部:左边状态胶囊,右边成功给下载按钮、失败给原因 +class AIRecordFooter extends StatelessWidget { + final AIRecordState state; + final String? remark; + final VoidCallback? onDownload; + final VoidCallback? onStatusTap; + + const AIRecordFooter( + {super.key, + required this.state, + this.remark, + this.onDownload, + this.onStatusTap}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minHeight: 26), //三态高度一致 + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AIStatusPill(state, + onTap: state == AIRecordState.success ? onStatusTap : null), + if (state == AIRecordState.success && onDownload != null) + AIDownloadBtn(onTap: onDownload) + else if (state == AIRecordState.failed) + Flexible( + child: Text( + _failedText(remark), + style: const TextStyle(color: Color(0xffF52C56), fontSize: 12), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +/// 状态胶囊:生成中 / 已完成(黄底)、生成失败(无底) +class AIStatusPill extends StatelessWidget { + final AIRecordState state; + final VoidCallback? onTap; + + const AIStatusPill(this.state, {super.key, this.onTap}); + + @override + Widget build(BuildContext context) { + final isFailed = state == AIRecordState.failed; + final text = switch (state) { + AIRecordState.queuing => '生成中', + AIRecordState.success => '已完成', + AIRecordState.failed => '生成失败', + }; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: isFailed + ? null + : BoxDecoration( + color: const Color(0x1AFFDB9E), + borderRadius: BorderRadius.circular(3)), + child: Text( + text, + style: textStyle( + 13, + isFailed + ? Colors.white.withValues(alpha: .55) + : const Color(0xffFFDB9E), + FontWeight.w400, + ), + ), + ), + ); + } +} + +/// 「一键下载」按钮 +class AIDownloadBtn extends StatelessWidget { + final VoidCallback? onTap; + + const AIDownloadBtn({super.key, this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + border: Border.all( + width: 0.5, color: Colors.white.withValues(alpha: .15)), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Image.asset('icon_ai_download.png'.aiPath, width: 18), + 2.5.sizeBoxW, + const Text('一键下载', + style: TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ), + ); + } +} + +/// 记录里的缩略图,[badge] 盖在左上角。 +/// [width] 三图并排的卡要传 90.w 按屏宽缩,窄屏才放得下 +class AIRecordThumb extends StatelessWidget { + final String? imageUrl; + final Widget? badge; + final VoidCallback? onTap; + final double width; + + const AIRecordThumb( + {super.key, this.imageUrl, this.badge, this.onTap, this.width = 90}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: width, + height: 90, + child: Stack( + children: [ + Container( + decoration: const BoxDecoration( + color: Color(0xff262626), + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: NetworkImageLoader( + imageUrl: imageUrl, + width: width, + height: 90, + borderRadius: 8), + ), + if (badge != null) badge!, + ], + ), + ), + ); + } +} + +/// 结果图:没出图先压一层黑蒙层,失败再盖个叉,成功才可点 +class AIResultThumb extends StatelessWidget { + final String? imageUrl; + final AIRecordState state; + final VoidCallback? onTap; + final double width; + + const AIResultThumb( + {super.key, + this.imageUrl, + required this.state, + this.onTap, + this.width = 90}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + AIRecordThumb( + imageUrl: imageUrl, + width: width, + onTap: state == AIRecordState.success ? onTap : null, + ), + if (state != AIRecordState.success) + Container( + width: width, + height: 90, + decoration: const BoxDecoration( + color: Color(0x80000000), + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ), + if (state == AIRecordState.failed) + Positioned.fill( + child: Center( + child: + Image.asset('ai_record_failed.webp'.aiPath, width: 20))), + ], + ); + } +} + +/// 缩略图左上角角标:模版(黄) / 素材(青),左上右下切角 +class AISourceBadge extends StatelessWidget { + final bool isTemplate; + + const AISourceBadge({super.key, this.isTemplate = true}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isTemplate ? const Color(0xffFFD460) : const Color(0xff03FCEB), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: Text( + isTemplate ? '模版' : '素材', + style: textStyle(12, const Color(0xff141414), FontWeight.w400), + ), + ); + } +} + +/// 两张图之间的连接符:+ 或 = +class AIRecordJoin extends StatelessWidget { + final bool isEquals; + final double gap; + + const AIRecordJoin({super.key, this.isEquals = false, this.gap = 3}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: gap), + child: Image.asset( + (isEquals ? 'ai_record_equals.png' : 'ai_record_add.webp').aiPath, + width: 18, + height: 18, + ), + ); + } +} diff --git a/lib/hj_page/ai/widgets/ai_square_cell.dart b/lib/hj_page/ai/widgets/ai_square_cell.dart new file mode 100644 index 0000000..153f9fb --- /dev/null +++ b/lib/hj_page/ai/widgets/ai_square_cell.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/video/simple_video_player_page.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/header_widget.dart'; +import 'package:hgdj/tools_base/widget/image_browser_page.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../hj_utils/widget_util.dart'; +import '../ai_sub_type/ai_function_logic.dart'; +import '../models/ai_square_model.dart'; + +/// AI 广场单条内容:作者信息 + 标题 + 按业务类型展示的图/视频 +class AISquareCell extends StatelessWidget { + final AISquareItemModel model; + final VoidCallback onTap; + + const AISquareCell({super.key, required this.model, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _headerView(), + 12.sizeBoxH, + _contentView(), + 12.sizeBoxH, + _imgView(), + ], + ), + ); + } + + Widget _imgView() { + return switch (AiTypeCode.fromServerCode(model.type)) { + AiType.imageChangeFace || + AiType.autoStrip => + _twoImgsView(model.originalImage, model.generateImage), + AiType.videoChangeFace => _videoChangeFace(), + AiType.imageToVideo => _imgToVideo(), + AiType.aiPaint => _aiDraw(), + _ => const SizedBox.shrink(), + }; + } + + Widget _twoImgsView(String? left, String? right) { + return Row( + children: [ + Expanded( + child: InkWell( + enableFeedback: false, + onTap: () => + ImageBrowserPage.open([left ?? ''], showSaveButton: true), + child: NetworkImageLoader( + imageUrl: left, + imgBorderRadius: + const BorderRadius.horizontal(left: Radius.circular(9)), + ), + ), + ), + Expanded( + child: InkWell( + enableFeedback: false, + onTap: () => + ImageBrowserPage.open([right ?? ''], showSaveButton: true), + child: NetworkImageLoader( + imageUrl: right, + imgBorderRadius: + const BorderRadius.horizontal(right: Radius.circular(9)), + ), + ), + ), + ], + ); + } + + Widget _aiDraw() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '“${model.originContent ?? ''}”', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Color(0xff03FCEB)), + ), + 12.sizeBoxH, + Row( + children: [ + Expanded( + child: InkWell( + enableFeedback: false, + onTap: () => ImageBrowserPage.open([model.generateImage ?? ''], + showSaveButton: true), + child: NetworkImageLoader(imageUrl: model.generateImage), + ), + ), + 10.sizeBoxW, + const Expanded(child: SizedBox.shrink()), + ], + ) + ], + ); + } + + Widget _imgToVideo() { + return Row( + children: [ + Expanded(child: _imgItem(model.originalImage)), + 10.sizeBoxW, + Expanded(child: _imgItem(model.generateImage)), + ], + ); + } + + Widget _videoChangeFace() { + return Row( + children: [ + Expanded( + child: _videoItem( + model.generateVideoCover, model.realGenerateVideoUrl)), + 10.sizeBoxW, + const Expanded(child: SizedBox.shrink()), + ], + ); + } + + Widget _contentView() { + return Text.rich(TextSpan(children: [ + if ((model.sortCode ?? 0) > 0) + WidgetSpan( + child: Container( + margin: const EdgeInsets.only(right: 8), + width: 30, + height: 15, + alignment: Alignment.center, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xffFF2264), Color(0x1AFF2264)], + ), + ), + child: Text( + '置顶', + style: textStyle(10, Colors.white, FontWeight.w500), + ), + )), + TextSpan( + text: model.title, + style: textStyle( + 14, Colors.white.withValues(alpha: .9), FontWeight.w500)) + ])); + } + + Widget _headerView() { + return Row( + children: [ + HeaderWidget( + headPath: model.portrait ?? '', + level: 0, + headWidth: 48, + headHeight: 48, + isCircle: false, + radius: 24, + ), + 12.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + model.name ?? '', + style: textStyle(14, Colors.white, FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Row( + children: [ + Text( + model.typeString, + style: textStyle(12, AppColors.actionRed, FontWeight.w400), + ), + 10.sizeBoxW, + Text( + DateTimeUtil.utcTurnYear(model.reviewAt), + style: textStyle(12, Colors.white.withValues(alpha: 0.55), + FontWeight.w400), + ) + ], + ) + ], + )), + 12.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: onTap, + child: Container( + width: 70, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset('make_the_same.png'.aiPath, width: 14, height: 14), + 2.sizeBoxW, + Text( + '制作同款', + style: textStyle(10, Colors.white, FontWeight.w400), + ) + ], + ), + ), + ) + ], + ); + } + + Widget _imgItem(String? img) { + return InkWell( + enableFeedback: false, + onTap: () => ImageBrowserPage.open([img ?? ''], showSaveButton: true), + child: NetworkImageLoader(imageUrl: img, borderRadius: 9), + ); + } + + Widget _videoItem(String? img, String url) { + return InkWell( + enableFeedback: false, + onTap: () => Get.to( + () => SimpleVideoPlayerPage(videoUrl: url, title: model.title ?? '')), + child: Stack( + alignment: Alignment.center, + children: [ + NetworkImageLoader(imageUrl: img, borderRadius: 9), + Image.asset('paly_btn.png'.aiPath, width: 36, height: 36), + ], + ), + ); + } +} diff --git a/lib/hj_page/ai/widgets/ai_video_record.dart b/lib/hj_page/ai/widgets/ai_video_record.dart new file mode 100644 index 0000000..c18fe2b --- /dev/null +++ b/lib/hj_page/ai/widgets/ai_video_record.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/image_browser_page.dart'; + +import '../models/ai_record_model.dart'; +import 'ai_record_parts.dart'; + +/// 图生视频 单条记录(原图 → 结果图),列表/分页/删除由 AIRecordLogic 统一管理 +class AIVideoRecord extends StatelessWidget { + final AiRecordModel model; + final int status; // 1 排队 2 成功 3 失败 + final VoidCallback? onDeleteCallback; + + const AIVideoRecord(this.model, + {super.key, required this.status, this.onDeleteCallback}); + + AIRecordState get _state => switch (status) { + 2 => AIRecordState.success, + 3 => AIRecordState.failed, + _ => AIRecordState.queuing, + }; + + String get _resultCover => + model.status == 3 ? (model.newImgUrl ?? '') : (model.imgUrl ?? ''); + + @override + Widget build(BuildContext context) { + return AIRecordCard( + state: _state, + time: model.createdAt, + remark: model.remark, + onDownload: _showNewPicture, + onStatusTap: _showNewPicture, + onDelete: () => onDeleteCallback?.call(), + child: _buildItemCell(), + ); + } + + void _showNewPicture() { + final url = model.newImgUrl ?? ''; + if (url.isEmpty) { + showToast('图片地址为空'); + return; + } + ImageBrowserPage.open([url], showSaveButton: true); + } + + Widget _buildItemCell() { + return SizedBox( + height: 90, + child: Row( + children: [ + AIRecordThumb( + imageUrl: model.imgUrl, + badge: + Image.asset('ai_record_org.png'.aiPath, width: 36, height: 18), + ), + const AIRecordJoin(isEquals: true), + AIResultThumb( + imageUrl: _resultCover, + state: _state, + onTap: _resultCover.isEmpty + ? null + : () => + ImageBrowserPage.open([_resultCover], showSaveButton: true), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/ai/widgets/pic_picker.dart b/lib/hj_page/ai/widgets/pic_picker.dart new file mode 100644 index 0000000..c1450f7 --- /dev/null +++ b/lib/hj_page/ai/widgets/pic_picker.dart @@ -0,0 +1,132 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:image_pickers/image_pickers.dart'; +import 'package:mobkit_dashed_border/mobkit_dashed_border.dart'; + +/// AI 功能的单图选择器:未选时是虚线占位框,选后是缩略图 + 右上角删除 +class PicPicker extends StatefulWidget { + /// 与 logic 共享同一份本地图片路径,选/删直接写回 + final List picList; + final double? width; + final double? height; + + const PicPicker({ + super.key, + required this.picList, + this.width, + this.height, + }); + + @override + State createState() => _PicPickerState(); +} + +class _PicPickerState extends State { + List get picList => widget.picList; + + void _delPic() { + if (picList.isNotEmpty) picList.removeAt(0); + setState(() {}); + } + + Future _addPic() async { + final paths = await _pickImg(); + if (paths.isEmpty) { + showToast("请选择图片"); + return; + } + picList + ..clear() + ..addAll(paths); + setState(() {}); + } + + //相册选图,只取 1 张;不做二次压缩,交给插件的 compressSize + Future> _pickImg() async { + final medias = await ImagePickers.pickerPaths( + uiConfig: UIConfig(uiThemeColor: AppColors.primaryColor), + galleryMode: GalleryMode.image, + selectCount: 1, + showCamera: true, + ); + final paths = + medias.map((e) => e.path ?? "").where((e) => e.isNotEmpty).toList(); + if (paths.length < medias.length) showToast("添加图片失败"); + return paths; + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.width, + height: widget.height, + child: picList.isEmpty ? _addBtn() : _picItem(), + ); + } + + // 已选图:点图预览,点右上角叉删除 + Widget _picItem() { + return GestureDetector( + onTap: () => ImagePickers.previewImages(picList, 0), + child: Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(picList.first), + width: widget.width, + height: widget.height, + fit: BoxFit.cover, + ), + ), + Positioned( + top: 0, + right: 0, + child: InkWell( + enableFeedback: false, + onTap: _delPic, + child: Padding( + padding: const EdgeInsets.all(6), + child: Image.asset("close_grey.png".commonImgPath, + width: 14, height: 14), + ), + ), + ), + ], + ), + ); + } + + // 未选图:虚线占位框 + Widget _addBtn() { + return GestureDetector( + onTap: _addPic, + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x0DFFFFFF), + border: DashedBorder.fromBorderSide( + dashLength: 2, + side: const BorderSide(color: Color(0xff656565)), + ), + borderRadius: const BorderRadius.all(Radius.circular(3)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('add_grey.png'.communityPath, width: 24), + 10.sizeBoxH, + const Text("添加图片", + style: TextStyle(fontSize: 12, color: Color(0xff999999))), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/cartoon/acg_widget_item.dart b/lib/hj_page/cartoon/acg_widget_item.dart new file mode 100644 index 0000000..badb871 --- /dev/null +++ b/lib/hj_page/cartoon/acg_widget_item.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../config/config.dart'; +import '../../hj_model/cartoon_media_info.dart'; + +//acg video item组件 +class AcgItemWidget extends StatelessWidget { + final CartoonMediaInfo? info; + final bool coverV; //竖屏封面 + final bool? heroUI; //是否需要hero动画 + final bool? showSymbol; //是否显示角标 + final Function()? tapCallback; + + const AcgItemWidget({ + super.key, + this.info, + this.coverV = true, + this.tapCallback, + this.heroUI = false, + this.showSymbol = true, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: tapCallback ?? () => pushToCartoonPage(info), + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return NetworkImageLoader( + imageUrl: coverV ? info?.coverV ?? '' : info?.coverH ?? '', + width: constraints.maxWidth, + height: constraints.maxHeight, + fit: BoxFit.cover, + ); + }, + ), + Positioned( + right: 6, + top: 6, + child: _buildLevelIcon(), + ), + if (info?.isAudiobooks == true) + Positioned( + left: 8, + top: 8, + height: 24, + width: 24, + child: Image.asset('voice_noval_icon.png'.videoPath), + ) + ], + )), + 6.sizeBoxH, + Row( + children: [ + Expanded( + child: Text( + info?.title ?? '', + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: .9), + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + ), + ) + ], + ), + 4.sizeBoxH, + Row( + children: [ + Expanded( + child: Text( + '${info?.episodeNumberStatus ?? ''} · ${info?.updateDesc}', + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 11, + color: _statusColor, + fontWeight: FontWeight.w400), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ) + ], + ), + ); + } + + Widget _buildLevelIcon() { + if (info?.permission == 1 && Config.coinMark) + return _levelTag('金币', const Color(0xff8B3E00)); + if (info?.permission == 0 && Config.vipMark) + return _levelTag('VIP', const Color(0xff141414)); + return const SizedBox.shrink(); + } + + // 金币/VIP 角标:渐变底 + 文字,仅文字与文字色不同 + Widget _levelTag(String text, Color textColor) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(2)), + gradient: + LinearGradient(colors: [Color(0xffFFE580), Color(0xffFACC15)]), + ), + child: Text( + text, + style: TextStyle(color: textColor, fontSize: 10, height: 1.6), + ), + ); + } + + Color get _statusColor { + return info?.updateStatus == 1 + ? const Color(0xffEEC76B) + : Colors.white.withValues(alpha: .45); + } +} diff --git a/lib/hj_page/cartoon/audio_player_bottomsheet.dart b/lib/hj_page/cartoon/audio_player_bottomsheet.dart new file mode 100644 index 0000000..f8a207d --- /dev/null +++ b/lib/hj_page/cartoon/audio_player_bottomsheet.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/acg/comic_chapters_model.dart'; +import 'package:hgdj/hj_page/live/live_widget.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; +import 'package:hgdj/tools_base/widget/stagger_in_item.dart'; + +import '../../assets_tool/app_colors.dart'; +import '../../hj_utils/widget_util.dart'; +import 'widget/free_badge.dart'; + +//有声小说 选择子集 +class AudioPlayerBottomSheet extends StatefulWidget { + final String title; + final List allAudiobooks; + final Function(int)? indexHandler; + final int? currentPlayIndex; + final String? unit; //子集单位(章/集...),与详情子集 title 格式统一 + final bool hasPermission; //整本是否已解锁:false 时前 N 集显示「免费」角标 + + AudioPlayerBottomSheet( + this.title, { + super.key, + required this.allAudiobooks, + this.indexHandler, + this.currentPlayIndex, + this.unit, + this.hasPermission = false, + }); + + @override + State createState() => _AudioPlayerBottomSheetState(); +} + +class _AudioPlayerBottomSheetState extends State { + int sortType = 1; //0-降序,1-升序 + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + constraints: BoxConstraints(maxHeight: Get.height / 2), + decoration: const BoxDecoration( + color: AppColors.primaryColor, + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Center(child: SheetHandleBar(color: Color(0xff989898))), + 18.sizeBoxH, + Row( + children: [ + Text( + widget.title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + Spacer(), + InkWell( + enableFeedback: false, + onTap: () => setState(() => sortType = sortType == 1 ? 0 : 1), + child: Row( + children: [ + AnimatedRotation( + turns: sortType == 1 ? 0 : 0.5, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + child: Image.asset('text_play_sort.png'.acgImgPath, + width: 18), + ), + 4.sizeBoxW, + Text( + sortType == 1 ? '正序' : '倒序', + style: textStyle(12, Colors.white.withValues(alpha: .45), + FontWeight.w400), + ), + ], + ), + ) + ], + ), + 18.sizeBoxH, + Expanded( + child: ListView.separated( + key: ValueKey(sortType), // 切换正/倒序时 key 变化→列表重建→item 重新错峰入场 + separatorBuilder: (context, index) => Divider( + height: 0.5, + color: Colors.white.withValues(alpha: .05), + ), + itemCount: widget.allAudiobooks.length, + itemBuilder: (context, index) { + final realIndex = getRealIndex(index); + final model = widget.allAudiobooks[realIndex]; + final select = widget.currentPlayIndex == realIndex; + return StaggerInItem( + index: index, + child: InkWell( + enableFeedback: false, + onTap: () { + Get.back(result: realIndex); + widget.indexHandler?.call(realIndex); + }, + child: Container( + padding: const EdgeInsets.only(bottom: 6), + child: Column( + children: [ + 16.sizeBoxH, + Row( + children: [ + if (select) ...[ + AudioWaveView(), + 8.sizeBoxW, + ], + Expanded( + child: Text( + //与详情/抽屉子集 title 格式一致:第X章 + '第${model.episodeNumber ?? 1}${widget.unit ?? ''}', + style: TextStyle( + color: select + ? AppColors.actionRed + : Colors.white + .withValues(alpha: .9), + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + //无任何权限时,前 N 集免费展示「免费」角标 + if (widget.hasPermission == false && + model.inFreeEpisode) ...[ + 8.sizeBoxW, + const FreeBadge(), + ], + 8.sizeBoxW, + const Icon( + Icons.navigate_next, + size: 18, + color: Color(0xffDCDCDC), + ) + ], + ), + 16.sizeBoxH, + ], + )), + )); + }, + ), + ) + ], + ), + ); + } + + int getRealIndex(int index) { + return (sortType == 1) ? index : widget.allAudiobooks.length - index - 1; + } +} diff --git a/lib/hj_page/cartoon/audio_player_content.dart b/lib/hj_page/cartoon/audio_player_content.dart new file mode 100644 index 0000000..b393f7a --- /dev/null +++ b/lib/hj_page/cartoon/audio_player_content.dart @@ -0,0 +1,372 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import 'audio_player_bottomsheet.dart'; +import 'widget/acg_source_manager.dart'; + +// 播放页面 +class AudioPlayerContent extends StatefulWidget { + final int index; + final AudioPlayerManager manager; + final ACGSourceManager sourceManager; + const AudioPlayerContent(this.sourceManager, + {super.key, required this.manager, this.index = 0}); + + @override + State createState() => _AudioPlayerContentState(); +} + +class _AudioPlayerContentState extends State { + AudioPlayerManager get manager => widget.manager; + AudioPlayMod playMod = AudioPlayMod.loopList; + + @override + void initState() { + super.initState(); + // manager 的 initPlayer / dispose 由 VoiceNovelReadLogic 统管: + // - onInit 已 initPlayer + // - onClose 已 dispose + // 此处不要再调 initPlayer,否则 4 个 stream listener 重复注册导致旧 sub 泄漏 + manager + .play(widget.sourceManager.allEpisodes[widget.index].getRealAudioUrl); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: manager.audioValue, + builder: (ctx, audioValue, child) { + double max = (audioValue.duration?.inSeconds ?? 1).toDouble(); + if (max == 0) max = 1; + final position = (audioValue.position?.inSeconds ?? 0).toDouble(); + final progress = position / max; + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + 12.sizeBoxH, + NetworkImageLoader( + imageUrl: widget.sourceManager.mediaInfo?.coverH ?? '', + width: 111, + height: 148, + ), + 18.sizeBoxH, + Row( + children: [ + Text( + '正在播放:第${(audioValue.onPlayIndex ?? 0) + 1}集', + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w500), + ), + ], + ), + 18.sizeBoxH, + _progressRow(audioValue, progress, max), + 14.sizeBoxH, + _controlRow(audioValue), + 18.sizeBoxH, + ], + ); + }, + ); + } + + // 进度条行:左右 ±10s 快进退、中间滑块、两侧时间 + Widget _progressRow( + AudioPlayerValue audioValue, double progress, double max) { + return Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + enableFeedback: false, + onTap: () => manager.seekTenMinsWithDirection(-1), + child: Image.asset('noval_time_back.png'.acgImgPath, width: 24), + ), + 8.sizeBoxH, + // 38px:原 35px 部分机型会裁掉 "MM:SS" 末位 + SizedBox( + width: 38, + height: 19, + child: Text( + buildMMSS(audioValue.position?.inSeconds ?? 0), + maxLines: 1, + softWrap: false, + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 12, + fontWeight: FontWeight.w500), + ), + ), + ], + ), + 10.sizeBoxW, + Expanded( + child: AudioSlider( + value: progress, + onChange: (value) => manager.seek((max * value).toInt()), //进度 + ), + ), + 10.sizeBoxW, + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + InkWell( + enableFeedback: false, + onTap: () => manager.seekTenMinsWithDirection(1), + child: + Image.asset('noval_time_forword.png'.acgImgPath, width: 24), + ), + 8.sizeBoxH, + SizedBox( + height: 19, + width: 38, + child: audioValue.duration == null + ? const CupertinoActivityIndicator(color: AppColors.actionRed) + : Text( + buildMMSS(audioValue.duration!.inSeconds), + maxLines: 1, + softWrap: false, + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1, + ), + ), + ), + ], + ), + ], + ); + } + + // 控制行:循环模式、上一集/播放暂停/下一集、播放列表 + Widget _controlRow(AudioPlayerValue audioValue) { + final isLoop = playMod == AudioPlayMod.loop; + final isPlaying = audioValue.isPlaying ?? false; + return Row( + children: [ + // 循环模式切换 + InkWell( + enableFeedback: false, + onTap: () => setState(() { + playMod = isLoop ? AudioPlayMod.loopList : AudioPlayMod.loop; + manager.setPlayMod(playMod); + }), + child: Column( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, anim) => + ScaleTransition(scale: anim, child: child), + child: Image.asset( + isLoop + ? 'single_cycle.png'.acgImgPath + : 'list_cycle.png'.acgImgPath, + // key 必须按状态区分,AnimatedSwitcher 才会把它当成新 child 触发动画 + key: ValueKey(isLoop ? 'loop' : 'loop_list'), + width: 24, + ), + ), + 2.sizeBoxH, + Text( + isLoop ? '单曲循环' : '多集循环', + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 12, + fontWeight: FontWeight.w400), + ), + ], + ), + ), + const Spacer(), + Row( + children: [ + InkWell( + enableFeedback: false, + onTap: () => manager.playForward(), + child: Image.asset('forward.png'.acgImgPath, width: 24), + ), + 40.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: () => isPlaying ? manager.pause() : manager.resume(), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, anim) => + ScaleTransition(scale: anim, child: child), + child: Image.asset( + isPlaying ? 'pause.png'.acgImgPath : 'play.png'.acgImgPath, + key: ValueKey(isPlaying ? 'pause' : 'play'), + width: 36, + ), + ), + ), + 40.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: () => manager.playNext(), + child: Image.asset('next.png'.acgImgPath, width: 24), + ), + ], + ), + const Spacer(), + InkWell( + enableFeedback: false, + onTap: _showPlaylist, + child: Column( + children: [ + Image.asset('play_list.png'.acgImgPath, width: 24), + 2.sizeBoxH, + Text( + '播放列表', + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 12, + fontWeight: FontWeight.w400), + ), + ], + ), + ), + ], + ); + } + + // 弹出播放列表,点击切集 + void _showPlaylist() { + Get.bottomSheet(AudioPlayerBottomSheet( + manager.sourceManger?.mediaInfo?.title ?? '', + currentPlayIndex: widget.sourceManager.index, + allAudiobooks: manager.sourceManger?.allEpisodes ?? [], + indexHandler: (index) => manager.playWithIndex(index), + unit: manager.sourceManger?.mediaInfo?.unit, + hasPermission: manager.sourceManger?.mediaInfo?.hasPermission ?? false, + )); + } +} + +/// 播放器进度条:内置 Material Slider,拖动/点击由框架处理; +/// 轨道和 thumb 用自定义 shape 套 hgdj 的橙红配色。 +class AudioSlider extends StatelessWidget { + final double? value; + final Function(double value) onChange; + + const AudioSlider({super.key, required this.value, required this.onChange}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 24, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + overlayShape: SliderComponentShape.noOverlay, + trackShape: GradientTrackShape(), + thumbShape: const GradientThumbShape(radius: 8), + ), + child: Slider( + min: 0, + max: 1, + value: (value ?? 0).clamp(0.0, 1.0), + onChanged: onChange, + ), + ), + ); + } +} + +/// 进度轨道:左侧(已播放)填 actionRed,右侧(未播放)填灰色 +class GradientTrackShape extends SliderTrackShape { + @override + Rect getPreferredRect({ + bool isDiscrete = false, + bool isEnabled = false, + Offset offset = Offset.zero, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + }) { + final trackHeight = sliderTheme.trackHeight ?? 2; + final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; + return Rect.fromLTWH( + offset.dx, trackTop, parentBox.size.width, trackHeight); + } + + @override + void paint( + PaintingContext context, + Offset offset, { + required Animation enableAnimation, + bool isDiscrete = false, + bool isEnabled = false, + required RenderBox parentBox, + Offset? secondaryOffset, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required Offset thumbCenter, + }) { + final trackRect = getPreferredRect( + parentBox: parentBox, + sliderTheme: sliderTheme, + offset: offset, + ); + final activePaint = Paint()..color = AppColors.actionRed; + final inactivePaint = Paint()..color = const Color(0xff3D3D3D); + // 左侧已播放 + context.canvas.drawRect( + Rect.fromLTRB( + trackRect.left, trackRect.top, thumbCenter.dx, trackRect.bottom), + activePaint, + ); + // 右侧未播放 + context.canvas.drawRect( + Rect.fromLTRB( + thumbCenter.dx, trackRect.top, trackRect.right, trackRect.bottom), + inactivePaint, + ); + } +} + +/// 圆形 thumb:外圈半透明 actionRed,内圈白 +class GradientThumbShape extends SliderComponentShape { + final double radius; + + const GradientThumbShape({this.radius = 8}); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => + Size.fromRadius(radius); + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required Size sizeWithOverflow, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double textScaleFactor, + required double value, + }) { + final canvas = context.canvas; + final outerPaint = Paint() + ..color = AppColors.actionRed.withValues(alpha: 0.7); + final innerPaint = Paint()..color = Colors.white; + canvas.drawCircle(center, radius, outerPaint); + canvas.drawCircle(center, radius - 3, innerPaint); + } +} diff --git a/lib/hj_page/cartoon/cartoon_detail_logic.dart b/lib/hj_page/cartoon/cartoon_detail_logic.dart new file mode 100644 index 0000000..5db6a4d --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_detail_logic.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; +import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.dart'; + +import '../../../hj_model/acg/comic_chapters_model.dart'; +import '../../../hj_model/cartoon_media_info.dart'; +import '../../../hj_utils/api_service/acg_service.dart'; +import '../../hj_utils/history_util.dart'; +import '../../tools_base/event_bus/event_bus_util.dart'; +import 'cartoon_read_page.dart'; +import 'widget/acg_source_manager.dart'; +import 'widget/cartoon_subset_alert.dart'; + +class CartoonDetailLogic extends GetxController + with GetTickerProviderStateMixin { + String? id; + CartoonMediaInfo? mediaInfo; + + bool showHeader = true; + double showOffset = 245; + bool get isCartoonType => manager.mediaInfo?.mediaType == 'image'; //是否是动画 + + // 推荐 tab 固定 3 个(漫画/视频/动漫),顺序由页面按当前内容类型动态排 + late TabController tabCtr = TabController(length: 3, vsync: this); + + late ScrollController scrollCtr = ScrollController() + ..addListener(() { + //滚过阈值切换顶部沉浸 header,状态变了才刷新 + final show = scrollCtr.offset <= showOffset; + if (show != showHeader) { + showHeader = show; + update(['header']); + } + }); + final ItemScrollController itemSCtrl = ItemScrollController(); + late ACGSourceManager manager; + late StreamSubscription subscription; + + CartoonDetailLogic({this.id}) + : manager = ACGSourceManager(id ?? ''); //初始化,加载子集 + + @override + void onReady() async { + super.onReady(); + // subscription 是 late,必须在任何 await 前初始化, + // 否则详情加载途中退出会让 onClose 的 cancel() 打到未初始化字段而崩 + subscription = eventBus.on((event) { + //列表未 attach(未 build/布局完)时 jumpTo 内部会 ! 取空崩溃,加保护 + if (itemSCtrl.isAttached) { + itemSCtrl.jumpTo(index: event.index ?? 0); + } + }); + // 先拿详情(含 freeEpisode),再拉子集,才能正确标记前 N 集免费 + await loadData(); + manager.fetchAllEpisodes(); + } + + @override + void onClose() { + subscription.cancel(); + eventBus.off(subscription); + tabCtr.dispose(); //修复泄漏 + scrollCtr.dispose(); //修复泄漏 + // manager 由页面的 ChangeNotifierProvider(create:) 在卸载时 dispose,本类不再重复释放 + super.onClose(); + } + + //获取媒体详情 + Future loadData() async { + mediaInfo = await ACGService.getMediaInfo(id ?? ''); + if (mediaInfo != null) { + HistoryUtil.insert(mediaInfo!, MediaStyle.Comics); + manager.mediaInfo = mediaInfo; + showOffset = mediaInfo?.mediaType == 'text' ? 300 : 320; + } else { + mediaInfo = CartoonMediaInfo(); + } + update(); + } + + //切换集数 + changeEpisode(int episode, bool jump) async { + manager.changeEpisodesIndex(episode, jump: jump); + play(); //播放 + } + + play() async { + //判断权限 + ComicChapterInfo? info = + await manager.getEpisodeHasPermisson(manager.index); + if (info == null) return; + Get.to(() => CartoonReadPage(manager: manager..index = manager.index)); + } + + //集数弹窗 + onMenuAction() async { + int episode = await Get.bottomSheet( + CartoonSubSetAlert(sourceManager: manager), + barrierColor: Colors.black.withValues(alpha: .2), + ); + if (episode > -1) { + changeEpisode(episode, true); + } + } +} diff --git a/lib/hj_page/cartoon/cartoon_detail_page.dart b/lib/hj_page/cartoon/cartoon_detail_page.dart new file mode 100644 index 0000000..4158a0e --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_detail_page.dart @@ -0,0 +1,593 @@ +import 'dart:math'; + +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/follow_button.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../hj_utils/const.dart'; +import '../../../hj_utils/screen.dart'; +import '../../../tools_base/loading/loading_center_widget.dart'; +import '../../alert/video/share_media_dialog.dart'; +import '../../hj_model/video_model.dart'; +import '../../hj_utils/widget_util.dart'; +import '../home/tag/cartoon_tag_page.dart'; +import 'cartoon_detail_logic.dart'; +import 'cartoon_recommend_page.dart'; +import 'widget/acg_expand_text.dart'; +import 'widget/acg_source_manager.dart'; +import 'widget/free_badge.dart'; + +//acg详情页,小说和漫画 合并 +class CartoonDetailPage extends StatefulWidget { + final String id; + final String? heroTag; + const CartoonDetailPage({super.key, this.id = '', this.heroTag}); + + @override + State createState() => _CartoonDetailPageState(); +} + +// 详情页可经「推荐页 → 再开详情页」成环并存,用 UniqueTagMixin 给每个实例唯一 tag +class _CartoonDetailPageState extends State + with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Color(0xff05000E), + body: GetBuilder( + tag: uniqueTag, + init: CartoonDetailLogic(id: widget.id), + builder: (logic) { + // manager 作为 ChangeNotifier 供子树 Consumer 监听; + // 用 create: 让 Provider 在页面卸载时自动 dispose manager(释放音频播放器+订阅),Flutter 层保证释放 + return ChangeNotifierProvider( + create: (_) => logic.manager, + child: Builder(builder: (_) { + if (logic.mediaInfo == null) return LoadingCenterWidget(); + if (logic.mediaInfo?.id == null) { + return CErrorWidget(retryOnTap: () => logic.loadData()); + } + return _buildContent(logic); + }), + ); + }, + ), + ); + } + + // 当前内容对应的推荐类型(小说等无对应 tab 时为 null) + MediaStyle? _selfStyle(CartoonDetailLogic logic) => + switch (logic.mediaInfo?.mediaType) { + 'video' => MediaStyle.Cartoon, + 'image' => MediaStyle.Comics, + _ => null, + }; + + // 推荐 tab 排序:与当前内容同类的排最前,其余按默认顺序 + List _orderedStyles(CartoonDetailLogic logic) { + const base = [MediaStyle.Comics, MediaStyle.Video, MediaStyle.Cartoon]; + final self = _selfStyle(logic); + if (self == null) return base; + return [self, ...base.where((s) => s != self)]; + } + + String _recTitle(MediaStyle s) => switch (s) { + MediaStyle.Video => '视频推荐', + MediaStyle.Cartoon => '动漫推荐', + _ => '漫画推荐', + }; + + // 单个推荐 tab:同类才传 tagId(拉相似),视频 cell 用大图 2 列 + Widget _recPage(CartoonDetailLogic logic, MediaStyle s) { + final isMatch = s == _selfStyle(logic); + final isVideo = s == MediaStyle.Video; + return CartoonRecommendPage( + mediaStyle: s, + mediaId: isMatch ? logic.mediaInfo?.tagDetails?.firstOrNull?.id : null, + crossAxisCount: isVideo ? 2 : 3, + childAspectRatio: isVideo ? 168 / 142 : 111 / 188, + ).keepAlive; + } + + Widget _buildContent(CartoonDetailLogic logic) { + final styles = _orderedStyles(logic); + return Column( + children: [ + Expanded( + child: Stack( + children: [ + ExtendedNestedScrollView( + controller: logic.scrollCtr, + onlyOneScrollInBody: true, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverToBoxAdapter(child: _buildImgHeader(logic)), //header + SliverToBoxAdapter(child: _buildMenu(logic)), //目录 + SliverToBoxAdapter( + child: _buildTabbar(logic, styles)) //推荐 tabbar + ]; + }, + //推荐 + body: TabBarView( + controller: logic.tabCtr, + children: styles.map((s) => _recPage(logic, s)).toList(), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: _buildTopHeader(logic), + ), + ], + )), + Padding( + // 底部垫上虚拟导航栏高度,避免按钮被遮挡(edge-to-edge) + padding: EdgeInsets.only(top: 10, bottom: 10 + screen.paddingBottom), + child: Row( + children: [ + 16.sizeBoxW, + if (logic.isCartoonType) ...[ + Consumer( + builder: (context, manager, child) { + bool liked = + manager.mediaInfo?.mediaStatus?.hasLiked == true; + return InkWell( + enableFeedback: false, + onTap: () => manager.onLikeAction(), + child: Container( + width: 108, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .2), + borderRadius: BorderRadius.circular(3), + ), + height: 44, + alignment: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + liked + ? 'acg_like_sel.png'.acgImgPath + : 'acg_like_nor.png'.acgImgPath, + width: 16, + ), + 10.sizeBoxW, + Text( + '喜欢', + style: textStyle( + 16, Color(0xff989898), FontWeight.w500), + ), + ], + )), + ); + }, + ), + 12.sizeBoxW, + ], + Expanded( + child: InkWell( + enableFeedback: false, + onTap: () => logic.play(), + child: Container( + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + height: 44, + alignment: Alignment.center, + child: Text( + '开始阅读', + style: textStyle(16, Colors.white, FontWeight.w500), + ), + ), + ), + ), + 16.sizeBoxW, + ], + ), + ), + ], + ); + } + + Widget _buildImgHeader(CartoonDetailLogic logic) { + return Stack( + children: [ + //背景图 + Positioned( + top: 0, + left: 0, + right: 0, + child: NetworkImageLoader( + imageUrl: logic.mediaInfo?.verticalCover ?? "", + borderRadius: 0, + height: 350, + alignment: Alignment.topCenter, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + (10 + screen.paddingTop).sizeBoxH, + Row( + children: [ + _buildBackView(), + Spacer(), + _buildCollAction(), + 12.sizeBoxW, + _buildShareView(logic), + ], + ).paddingSymmetric(horizontal: 16), + 154.sizeBoxH, + Text( + logic.mediaInfo?.title ?? "", + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w900, + fontFamily: 'Roboto', + ), + ).paddingSymmetric(horizontal: 16), + 12.sizeBoxH, + Container( + padding: EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Color(0xff05000E), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Column( + children: [ + 18.sizeBoxH, + Row( + children: [ + _buildStateItem( + 'view_count.png'.communityPath, + logic.mediaInfo?.countBrowse?.countStr ?? '', + ), + 12.sizeBoxW, + _buildSpe(), + 12.sizeBoxW, + // 点赞状态/数量随 manager.notifyListeners 刷新(onLikeAction 不会触发 GetX update) + Consumer( + builder: (_, manager, __) => _buildStateItem( + manager.mediaInfo?.mediaStatus?.hasLiked == true + ? 'community_thumb_sel.png'.communityPath + : 'like_count.png'.communityPath, + manager.mediaInfo?.countLike?.countStr ?? '', + ), + ), + 12.sizeBoxW, + _buildSpe(), + 12.sizeBoxW, + // 0:会员 1:金币购买 2:免费 + if (logic.mediaInfo?.permission == 0) ...[ + Text( + 'VIP', + style: + textStyle(12, Color(0xff989898), FontWeight.w400), + ), + ] else if (logic.mediaInfo?.permission == 1) ...[ + Text( + '${logic.mediaInfo?.price ?? 0}金币', + style: + textStyle(12, Color(0xff989898), FontWeight.w400), + ), + ], + ], + ), + //详情 + if (logic.mediaInfo?.summary?.isNotEmpty == true) ...[ + 12.sizeBoxH, + Padding( + padding: EdgeInsets.fromLTRB(0, 12, 0, 0), + child: AcgShowMoreTextWidget( + summary: logic.mediaInfo?.summary ?? '', + maxWidth: Get.width - 10, + ), + ) + ], + //标签 + if ((logic.mediaInfo?.tagDetails?.length ?? 0) != 0) ...[ + 12.sizeBoxH, + SizedBox( + height: 26, + child: ListView.separated( + separatorBuilder: (context, index) => 6.sizeBoxW, + itemCount: logic.mediaInfo?.tagDetails?.length ?? 0, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + TagsBean? tag = logic.mediaInfo?.tagDetails?[index]; + return InkWell( + enableFeedback: false, + onTap: () { + Get.to(CartoonTagPage( + title: tag?.name ?? '', + sId: tag?.id ?? '', + )); + }, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(3)), + height: 26, + child: Text( + tag?.name ?? '', + style: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + )), + ); + }, + ), + ), + 12.sizeBoxH, + ], + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildSpe() { + return Container( + color: Colors.white.withValues(alpha: 0.1), + height: 18, + width: 0.5, + ); + } + + Widget _buildStateItem(String img, String name) { + return Row( + children: [ + Image.asset(img, width: 16), + 4.sizeBoxW, + Text( + name, + style: textStyle(12, Color(0xff989898), FontWeight.w400), + ) + ], + ); + } + + Widget _buildTabbar(CartoonDetailLogic logic, List styles) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TabBar( + controller: logic.tabCtr, + padding: EdgeInsets.symmetric(horizontal: 8), + tabs: styles + .map((s) => Padding( + padding: const EdgeInsets.only(bottom: 5.0), + child: Text(_recTitle(s)), + )) + .toList(), + tabAlignment: TabAlignment.center, + isScrollable: true, + labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + labelColor: Colors.white.withValues(alpha: 0.9), + unselectedLabelColor: Colors.white.withValues(alpha: .55), + unselectedLabelStyle: + TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + indicator: CustomIndicator( + isGradient: true, + width: 13, + height: 3, + borderRadius: BorderRadius.circular(1.5), + )), + ], + ); + } + + Widget _buildMenu(CartoonDetailLogic logic) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 18.sizeBoxH, + Row( + children: [ + Text( + logic.isCartoonType + ? '选集' + : '共${logic.mediaInfo?.totalEpisode ?? 0}${logic.mediaInfo?.unit}', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + Spacer(), + Row( + children: [ + InkWell( + enableFeedback: false, + onTap: logic.onMenuAction, + child: Text( + logic.isCartoonType + ? '全${logic.mediaInfo?.totalEpisode ?? 0}${logic.mediaInfo?.unit}' + : '目录', + style: TextStyle( + color: Color(0xFFA7A7A7), + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + 2.sizeBoxW, + Image.asset( + 'arrow_right_grey.webp'.commonImgPath, + color: Color(0xff989898), + width: 24, + ) + ], + ) + ], + ), + 18.sizeBoxH, + Consumer( + builder: (context, provider, child) { + return ListView.builder( + padding: EdgeInsets.zero, + physics: NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemBuilder: (context, index) { + final epm = provider.allEpisodes[index]; + return InkWell( + enableFeedback: false, + onTap: () => logic.changeEpisode(index, false), + child: Container( + padding: EdgeInsets.symmetric( + vertical: logic.isCartoonType ? 6 : 12), + child: Row( + children: [ + Flexible( + child: Text( + '第${epm.episodeNumber ?? ''}${provider.mediaInfo?.unit}${logic.isCartoonType ? '' : ' ${epm.name}'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle( + 16, + provider.index == index + ? Colors.white.withValues(alpha: .9) + : Colors.white.withValues(alpha: .45), + provider.index == index + ? FontWeight.w500 + : FontWeight.w400, + ), + ), + ), + //无任何权限时,前 N 集免费展示「免费」角标 + if (provider.mediaInfo?.hasPermission == false && + epm.inFreeEpisode) ...[ + 8.sizeBoxW, + const FreeBadge(), + ], + ], + ), + ), + ); + }, + itemCount: min(4, provider.allEpisodes.length), + ); + }, + ), + 18.sizeBoxH, + if (logic.isCartoonType) 0.5.line, + 18.sizeBoxH, + ], + ), + ); + } + + Widget _buildBackView() { + return InkWell( + enableFeedback: false, + child: Image.asset( + 'back_circle_grey.png'.commonImgPath, + width: 24, + ), + onTap: () => Get.back(), + ); + } + + Widget _buildShareView(CartoonDetailLogic logic) { + return InkWell( + enableFeedback: false, + onTap: () { + Get.dialog( + ShareMediaDialog( + videoModel: VideoModel() + ..id = logic.mediaInfo?.id + ..cover = logic.mediaInfo?.coverH, + ), + ); + }, + child: Image.asset("acg_share.png".acgImgPath, width: 24), + ); + } + + Widget _buildTopHeader(CartoonDetailLogic logic) { + return GetBuilder( + tag: uniqueTag, + id: 'header', + builder: (_) { + return Offstage( + offstage: logic.showHeader, + child: Container( + padding: + EdgeInsets.only(left: 16, right: 16, top: screen.paddingTop), + height: 44 + screen.paddingTop, + color: Color(0xff05000E), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: + Icon(Icons.arrow_back_ios, size: 24, color: Colors.white), + ), + 8.sizeBoxW, + Expanded( + child: Text( + logic.mediaInfo?.title ?? '', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + _buildCollAction(), + 12.sizeBoxW, + _buildShareView(logic), + ], + ), + ), + ); + }, + ); + } + + Widget _buildCollAction() { + return Consumer( + builder: (context, manager, child) { + bool col = manager.mediaInfo?.mediaStatus?.hasCollected ?? false; + return FollowButton( + mediaId: manager.mediaInfo?.id ?? '', + isFollow: col, + followType: FollowEnum.cartoon, + successsAction: (isSuccess) { + manager.updateCollectState(isSuccess); + if (isSuccess) { + showToast('收藏成功'); + } + }, + ); + }, + ); + } +} diff --git a/lib/hj_page/cartoon/cartoon_read_logic.dart b/lib/hj_page/cartoon/cartoon_read_logic.dart new file mode 100644 index 0000000..787d813 --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_read_logic.dart @@ -0,0 +1,210 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/media_content.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/item_positions_listener.dart'; +import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.dart'; + +import 'widget/acg_source_manager.dart'; + +class CartoonReadLogic extends GetxController { + final ACGSourceManager manager; + + MediaContent? detailInfo; + int currentEpisode = 0; // 当前子集数,从 0 开始 + bool showMenu = false; + int readIndex = 1; // 当前在第几张图 + bool showProgress = false; // 展示进度 + bool sortType = true; // 排序类型 true-正序 false-倒序 + bool autoPlay = false; // 是否自动播放 + double readScale = 0; // 播放比例 + double bottomHeight = 155; + int animationTime = 250; // 显示时间(ms) + Timer? _timer; + bool _isSeeking = false; // 拖进度条中:此时的滚动由 slider 主导,别让滚动回调反过来改 slider/菜单 + int _seekTarget = -1; // 本次拖动已跳到的图,-1 表示没在拖 + + final ItemScrollController itemScrollCtr = ItemScrollController(); + final ScrollOffsetController scrollOffsetCtr = ScrollOffsetController(); + final ItemPositionsListener itemListener = ItemPositionsListener.create(); + + int get imgsLength => detailInfo?.urlSet?.length ?? 1; + + // 最后一张图的下标,无图时给 0,避免 clamp 传入负数上界 + int get _maxImgIndex => imgsLength > 0 ? imgsLength - 1 : 0; + + CartoonReadLogic(this.manager) : currentEpisode = manager.index; + + @override + void onInit() { + super.onInit(); + itemListener.itemPositions.addListener(_onItemPositionsChange); + } + + @override + void onReady() { + super.onReady(); + loadData(); + } + + @override + void onClose() { + _stopTimer(); + itemListener.itemPositions.removeListener(_onItemPositionsChange); + super.onClose(); + } + + // ========== 公开方法 ========== + // 首次进入加载当前章(也用于错误页重试,返回 Future 才有 loading + 防连点) + Future loadData() => _openEpisode(currentEpisode, isFirstLoad: true); + + // true 下一集 / false 上一集 + void loadOtherEpisode(bool next) { + final index = next ? currentEpisode + 1 : currentEpisode - 1; + if (index < 0) { + showToast('这是第一${manager.mediaInfo?.unit}了喔~'); + return; + } + if (index > manager.allEpisodes.length - 1) { + showToast('这是最后一${manager.mediaInfo?.unit}了喔~'); + return; + } + _openEpisode(index); + } + + // 目录点击切章 + void loadDataWithIndex(String id) { + // 目录列表可能倒序,按 id 定位 manager 中真实下标(不依赖"集号==下标+1",也天然不会越界) + final realIndex = manager.allEpisodes.indexWhere((e) => e.id == id); + if (realIndex < 0) { + showToast('数据错误,请联系客服'); + return; + } + _openEpisode(realIndex); + } + + // 拖动进度条跳图:跨图才跳,同一张图内的细微拖动不重复 jumpTo + void updateReadPicIndex(double value) { + _isSeeking = true; + readScale = value; + update(['slider']); + // 和 _seekTarget 比而不是和 readIndex 比:readIndex 是「最后一张可见图」, + // 这里的 target 是「要对齐到顶部的图」,两者语义不同,混用会漏跳 + final target = (_maxImgIndex * value).round(); // 按最大下标折算,天然不越界 + if (target == _seekTarget) return; + _seekTarget = target; + readIndex = target; + if (itemScrollCtr.isAttached) itemScrollCtr.jumpTo(index: target); + } + + // 松手:jumpTo 的位置回调下一帧才到,延后一帧再放开 + void onSeekEnd(double value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // 拖动期间位置回调被屏蔽,松手补一次触底结算(停自动播放); + // 放在解锁之前,免得这一步引起的滚动又被 onScrollAction 收了菜单 + _checkReachBottom(); + _isSeeking = false; + _seekTarget = -1; + }); + // 没有待渲染的帧时 postFrame 回调会一直挂着,这里确保有一帧 + WidgetsBinding.instance.ensureVisualUpdate(); + } + + void changeShowMenu() { + showMenu = !showMenu; + update(['header', 'footer', 'like']); + } + + // 用户点「自动」按钮:开启的同时收起菜单 + void onChangeAutoPlay(bool play) { + showMenu = !play; + if (play) { + update(['header', 'footer', 'autoplay', 'like']); + Future.delayed(const Duration(milliseconds: 300)).then((_) { + autoPlay = true; + update(['autoplay']); + }); + _startTimer(); + } else { + _stopTimer(); + autoPlay = false; + update(['header', 'footer', 'autoplay', 'like']); + } + } + + void toggleProgress() { + showProgress = !showProgress; + update(['progress']); + } + + void toggleSort() { + sortType = !sortType; + update(['menu']); + } + + void onScrollAction(ScrollController scrollController) { + // 拖进度条引起的滚动不能收菜单,否则进度条自己滑出屏幕 + if (_isSeeking || !showMenu) return; + showMenu = false; + update(['header', 'footer', 'like']); + } + + // ========== 私有方法 ========== + /// 切章统一入口:权限/缓存都在 manager 里,这里只管状态重置和刷新 + /// [isFirstLoad] 失败时才进错误页 + Future _openEpisode(int index, {bool isFirstLoad = false}) async { + // 首次进入页面已有全屏 loading,不再叠遮罩 + final model = await manager.getComicsMediaContent( + index: index, showLoading: !isFirstLoad); + if (model == null) { + if (isFirstLoad) + detailInfo = MediaContent(); // 空 model → id 为 null → 页面走错误态 + update(); + return; + } + currentEpisode = index; + detailInfo = model; + readIndex = 0; + readScale = 0; + update(); + // 首次加载时列表还没 build,controller 未 attach,跳转会空断言崩 + if (itemScrollCtr.isAttached) itemScrollCtr.jumpTo(index: 0); + } + + void _onItemPositionsChange() { + if (_isSeeking) return; // 拖动中:slider 值已由手指决定,再反写会和 jumpTo 来回抖 + final positions = itemListener.itemPositions.value; + if (positions.isEmpty) return; // 无图/列表重建时为空,取 first 会抛 StateError + readIndex = positions.last.index; + _checkReachBottom(); + // 分母用最大下标,滚到底才是满进度 + final count = detailInfo?.urlSet?.length ?? 0; + readScale = count > 1 ? (readIndex / (count - 1)).clamp(0.0, 1.0) : 0; + update(['slider']); + } + + // 触底:停自动播放(顺带弹回菜单) + void _checkReachBottom() { + final positions = itemListener.itemPositions.value; + if (positions.isEmpty) return; + final count = detailInfo?.urlSet?.length ?? 0; + // 首图还在视野内就不算触底(整章不足一屏时不处理) + if (positions.first.index == 0 || positions.last.index != count - 1) return; + onChangeAutoPlay(false); + } + + void _startTimer() { + _timer?.cancel(); // 防重入:先停旧的再建新的,避免重复开启时旧定时器泄漏 + _timer = Timer.periodic(const Duration(milliseconds: 500), (_) { + scrollOffsetCtr.animateScroll( + offset: 50, duration: const Duration(milliseconds: 500)); + }); + } + + void _stopTimer() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/lib/hj_page/cartoon/cartoon_read_page.dart b/lib/hj_page/cartoon/cartoon_read_page.dart new file mode 100644 index 0000000..cbfbb41 --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_read_page.dart @@ -0,0 +1,511 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/comment/comment_alert.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/follow_button.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.dart'; +import 'package:hgdj/tools_base/widget/stagger_in_item.dart'; +import 'package:provider/provider.dart'; + +import '../../alert/video/share_media_dialog.dart'; +import '../../hj_utils/widget_util.dart'; +import 'cartoon_read_logic.dart'; +import 'widget/acg_source_manager.dart'; + +// 漫画阅读页面 +class CartoonReadPage extends StatelessWidget { + final ACGSourceManager manager; + const CartoonReadPage({super.key, required this.manager}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: CartoonReadLogic(manager), + builder: (logic) => Scaffold( + body: ChangeNotifierProvider.value( + value: manager, + child: _buildBody(context, logic), + ), + drawer: _buildDrawer(logic), + ), + ); + } + + Widget _buildBody(BuildContext context, CartoonReadLogic logic) { + if (logic.detailInfo == null) return const LoadingCenterWidget(); + if (logic.detailInfo?.id == null) + return CErrorWidget(retryOnTap: logic.loadData); + return Stack( + alignment: Alignment.center, + children: [ + InkWell( + enableFeedback: false, + onTap: logic.changeShowMenu, + child: ScrollablePositionedList.builder( + scrollAction: logic.onScrollAction, + scrollOffsetController: logic.scrollOffsetCtr, + itemPositionsListener: logic.itemListener, + itemScrollController: logic.itemScrollCtr, + itemCount: logic.imgsLength, + itemBuilder: (_, index) => NetworkImageLoader( + imageUrl: logic.detailInfo?.urlSet?[index] ?? '', + fit: BoxFit.cover, + borderRadius: 0, + ), + ), + ), + _buildHeader(logic), + _buildFooter(context, logic), + _buildLiked(logic), + _buildAutoPlay(logic), + ], + ); + } + + // header bar + Widget _buildHeader(CartoonReadLogic logic) { + final headerHeight = screen.paddingTop + 50; + return GetBuilder( + id: 'header', + builder: (_) => AnimatedPositioned( + duration: Duration(milliseconds: logic.animationTime), + top: logic.showMenu ? 0 : -headerHeight, + left: 0, + right: 0, + child: Container( + color: Colors.black, + height: headerHeight, + padding: EdgeInsets.fromLTRB(10, screen.paddingTop + 8, 10, 8), + child: Row( + children: [ + InkWell( + enableFeedback: false, + onTap: () => Get.back(), + child: const Icon(Icons.arrow_back_ios_new, + size: 24, color: Colors.white), + ), + 10.sizeBoxW, + Expanded( + child: Text( + logic.detailInfo?.name ?? '', + style: TextStyle( + fontSize: 18.sp, + color: Colors.white, + fontWeight: FontWeight.w600), + ), + ), + 21.sizeBoxW, + Consumer( + builder: (ctx, manager, _) { + final col = + manager.mediaInfo?.mediaStatus?.hasCollected ?? false; + return FollowButton( + mediaId: manager.mediaInfo?.id ?? '', + isFollow: col, + followType: FollowEnum.cartoon, + successsAction: (isSuccess) { + manager.updateCollectState(isSuccess); + if (isSuccess) showToast('收藏成功'); + }, + ); + }, + ), + 12.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: () => Get.dialog( + ShareMediaDialog( + videoModel: VideoModel() + ..id = manager.mediaInfo?.id + ..cover = manager.mediaInfo?.coverH, + ), + ), + child: Image.asset('acg_share.png'.acgImgPath, width: 24), + ), + ], + ), + ), + ), + ); + } + + Widget _buildFooter(BuildContext context, CartoonReadLogic logic) { + return GetBuilder( + id: 'footer', + builder: (_) => AnimatedPositioned( + duration: Duration(milliseconds: logic.animationTime), + bottom: logic.showMenu ? 0 : -logic.bottomHeight, + left: 0, + right: 0, + child: Column( + children: [ + 15.sizeBoxH, + GetBuilder( + id: 'progress', + builder: (_) => AnimatedSlide( + offset: logic.showProgress ? Offset.zero : const Offset(0, 1), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + child: _buildProgressView(context, logic), + ), + ), + Container( + color: Colors.black, + padding: const EdgeInsets.fromLTRB(16, 18, 16, 25), + child: Row( + children: [ + Expanded( + child: Builder( + builder: (ctx) => _buildActionItem( + title: '目录', + icon: 'cartoon_menu.png'.acgImgPath, + action: () => Scaffold.of(ctx).openDrawer(), + ), + ), + ), + Expanded( + child: _buildActionItem( + title: '评论', + icon: 'cartoon_comment.png'.acgImgPath, + action: () => showCommentDialog( + logic.manager.mediaInfo?.id ?? '', + objType: logic.manager.mediaInfo?.commentType ?? '', + ), + ), + ), + Expanded( + child: _buildActionItem( + title: '进度', + icon: 'cartoon_progress.png'.acgImgPath, + action: logic.toggleProgress, + ), + ), + Expanded( + child: _buildActionItem( + title: '自动', + icon: 'cartoon_auto_play.png'.acgImgPath, + action: () => logic.onChangeAutoPlay(true), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildActionItem( + {String? title, String? icon, VoidCallback? action, Color? color}) { + return InkWell( + enableFeedback: false, + onTap: action, + child: Column( + children: [ + Image.asset(icon ?? '', width: 24), + 4.sizeBoxH, + Text( + title ?? '', + style: TextStyle( + color: color ?? Colors.white.withValues(alpha: .45), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ); + } + + // 进度条 + 上/下一篇 + Widget _buildProgressView(BuildContext context, CartoonReadLogic logic) { + return Container( + color: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + InkWell( + enableFeedback: false, + onTap: () => logic.loadOtherEpisode(false), + child: Column( + children: [ + Image.asset('common_back_new.png'.commonImgPath, width: 24), + 4.sizeBoxH, + Text('上一篇', + style: textStyle(12, Colors.white, FontWeight.w400)), + ], + ), + ), + 13.sizeBoxW, + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 4, + thumbShape: + const RoundSliderThumbShape(enabledThumbRadius: 8.0), + overlayShape: + const RoundSliderOverlayShape(overlayRadius: 12.0), + tickMarkShape: + const RoundSliderTickMarkShape(tickMarkRadius: 10.0), + trackShape: const RoundedRectSliderTrackShape(), + overlayColor: Colors.white.withValues(alpha: .3), + activeTrackColor: const Color(0xffFFD460), + inactiveTrackColor: Colors.white.withValues(alpha: .3), + thumbColor: const Color(0xffFFD460), + inactiveTickMarkColor: Colors.amber, + ), + child: GetBuilder( + id: 'slider', + builder: (_) => Slider( + min: 0, + max: 1, + value: logic.readScale, + onChanged: logic.updateReadPicIndex, + onChangeEnd: logic.onSeekEnd, + ), + ), + ), + ), + 13.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: () => logic.loadOtherEpisode(true), + child: Column( + children: [ + Transform.rotate( + angle: pi, + child: Image.asset('common_back_new.png'.commonImgPath, + width: 24), + ), + 4.sizeBoxH, + Text('下一篇', + style: textStyle(12, Colors.white, FontWeight.w400)), + ], + ), + ), + ], + ), + ); + } + + // 喜欢按钮 + Widget _buildLiked(CartoonReadLogic logic) { + return GetBuilder( + id: 'like', + builder: (_) => AnimatedPositioned( + duration: Duration(milliseconds: logic.animationTime), + bottom: 160, + right: logic.showMenu ? 0 : -120, + child: Consumer( + builder: (_, manager, __) { + final liked = manager.mediaInfo?.mediaStatus?.hasLiked == true; + return InkWell( + enableFeedback: false, + onTap: manager.onLikeAction, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: .5), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(999), + bottomLeft: Radius.circular(999), + ), + ), + alignment: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + liked + ? 'acg_like_sel.png'.acgImgPath + : 'acg_like_nor.png'.acgImgPath, + width: 24, + color: liked ? null : Colors.white, + ), + 5.sizeBoxW, + Text('喜欢', + style: textStyle(12, Colors.white, FontWeight.w400)), + ], + ), + ), + ); + }, + ), + ), + ); + } + + // 自动播放按钮 + Widget _buildAutoPlay(CartoonReadLogic logic) { + return Positioned( + bottom: 30, + child: GetBuilder( + id: 'autoplay', + builder: (_) => AnimatedScale( + scale: logic.autoPlay ? 1 : 0, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + child: IgnorePointer( + ignoring: !logic.autoPlay, + child: GestureDetector( + onTap: () => logic.onChangeAutoPlay(false), + child: Container( + height: 56, + width: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + color: Colors.white, + ), + child: Center( + child: Image.asset('cartoon_auto_play.gif'.acgImgPath, + width: 24), + ), + ), + ), + ), + ), + ), + ); + } + + // 章节抽屉 + Widget _buildDrawer(CartoonReadLogic logic) { + return GetBuilder( + id: 'menu', + builder: (_) => Container( + color: AppColors.primaryColor, + width: 210, + height: screen.screenHeight, + child: Column( + children: [ + 80.sizeBoxH, + Row( + children: [ + 16.sizeBoxW, + Expanded( + child: Text( + logic.manager.mediaInfo?.title ?? '', + style: TextStyle( + fontSize: 20, + color: Colors.white.withValues(alpha: 0.9), + fontWeight: FontWeight.w900, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + 18.sizeBoxH, + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + color: AppColors.actionRed, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '共${logic.manager.mediaInfo?.totalEpisode}话 ${logic.manager.mediaInfo?.getDetailUpdateString ?? ''}', + style: const TextStyle( + fontSize: 14, + color: Color(0xff141414), + fontWeight: FontWeight.w400), + ), + InkWell( + enableFeedback: false, + onTap: logic.toggleSort, + child: Row( + children: [ + AnimatedRotation( + turns: logic.sortType ? 0 : 0.5, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + child: Image.asset('text_play_sort.png'.acgImgPath, + color: const Color(0xff141414), + width: 16, + height: 16), + ), + 4.sizeBoxW, + Text( + logic.sortType ? '正序' : '倒序', + style: const TextStyle( + fontSize: 14, + color: Color(0xff141414), + fontWeight: FontWeight.w400), + ), + ], + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + key: ValueKey(logic.sortType), + itemCount: logic.manager.allEpisodes.length, + padding: const EdgeInsets.only(top: 6, bottom: 20), + itemBuilder: (_, index) { + // 不使用 list.reverse(items 不够会从底部排),采用数据倒序方法 + final info = logic.sortType + ? logic.manager.allEpisodes[index] + : logic.manager.allEpisodes[ + logic.manager.allEpisodes.length - index - 1]; + return StaggerInItem( + index: index, + child: GestureDetector( + onTap: () { + Get.back(); + if (logic.detailInfo?.id == info.id) + return; //点的就是当前章,不重复加载 + logic.loadDataWithIndex(info.id ?? ''); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + height: 40, + child: Row( + children: [ + _buildDrawerItem( + //与详情/弹窗子集 title 格式一致:第X话(漫画不带 name) + '第${info.episodeNumber ?? ''}${logic.manager.mediaInfo?.unit ?? ''}', + logic.detailInfo?.id == info.id, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildDrawerItem(String title, bool select) { + return Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 16.sp, + color: select + ? AppColors.actionRed + : Colors.white.withValues(alpha: 0.55), + fontWeight: select ? FontWeight.w500 : FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + } +} diff --git a/lib/hj_page/cartoon/cartoon_recommend_logic.dart b/lib/hj_page/cartoon/cartoon_recommend_logic.dart new file mode 100644 index 0000000..6d22fe0 --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_recommend_logic.dart @@ -0,0 +1,45 @@ +//小说/视频推荐 +import 'package:hgdj/hj_utils/api_service/acg_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +import '../../hj_utils/api_service/common_service.dart'; + +class CartoonRecommendLogic extends ListBaseLogic { + final String? mediaId; //acg 推荐的 tagId + final MediaStyle mediaStyle; //推荐类型:Video(真人视频) / Cartoon(动漫) / Comics(漫画) + final String? videoTagId; //视频(SP)推荐的 tagId,按当前视频 tag 拉同类 + + CartoonRecommendLogic( + {this.mediaId, this.mediaStyle = MediaStyle.Comics, this.videoTagId}); + + @override + void onReady() { + super.onReady(); + loadData(); + } + + //isRefresh 下拉刷新/加载更多;showLoading 切换时先清空显示 loading + void loadData({bool isRefresh = true, bool showLoading = false}) { + if (showLoading) { + dataList = null; + update(); + } + fetchData(isRefresh: isRefresh, fetch: _fetch); + } + + //按类型分流:视频走 getRecommendList,漫画/动漫走 comicsRecommendList + Future<(List?, bool)> _fetch(int page) async { + if (mediaStyle == MediaStyle.Video) { + final resp = await CommonService.getRecommendList( + pageNumber: page, + pageSize: 12, + tagId: videoTagId, + newsType: mediaStyle.rankParam); + return (resp?.videos, resp?.hasNext == true); + } + final model = await ACGService.comicsRecommendList(page, 12, + tagId: mediaId, mediaType: mediaStyle.rankParam); + return (model?.list, model?.hasNext == true); + } +} diff --git a/lib/hj_page/cartoon/cartoon_recommend_page.dart b/lib/hj_page/cartoon/cartoon_recommend_page.dart new file mode 100644 index 0000000..f35916e --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_recommend_page.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/cartoon_media_info.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +import '../../hj_model/video_model.dart'; +import '../home/home_cell_style/video_simple_cell.dart'; +import 'acg_widget_item.dart'; +import 'cartoon_recommend_logic.dart'; + +class CartoonRecommendPage extends StatefulWidget { + final String? mediaId; + final String? videoTagId; //视频(SP)推荐按当前视频 tag 拉同类 + final EdgeInsetsGeometry? padding; + final Function()? tapAction; + final MediaStyle mediaStyle; //推荐类型:Video(真人视频) / Cartoon(动漫) / Comics(漫画) + final int crossAxisCount; //网格列数 + final double childAspectRatio; //网格宽高比 + final Function(VideoModel model)? onVideoTap; //视频 cell 点击:不传则走默认 push + final Function(CartoonMediaInfo model)? onAcgTap; //acg cell 点击:不传则走默认 push + + const CartoonRecommendPage({ + super.key, + this.mediaId, + this.tapAction, + this.padding = const EdgeInsets.only(left: 16, right: 16, top: 10), + this.mediaStyle = MediaStyle.Comics, + this.videoTagId, + this.crossAxisCount = 3, + this.childAspectRatio = 111 / 188, + this.onVideoTap, + this.onAcgTap, + }); + + @override + State createState() => _CartoonRecommendPageState(); +} + +class _CartoonRecommendPageState extends State + with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: CartoonRecommendLogic( + mediaId: widget.mediaId, + mediaStyle: widget.mediaStyle, + videoTagId: widget.videoTagId, + ), + builder: (logic) { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.isEmptyData) + return CErrorWidget(retryOnTap: () => logic.loadData()); + final list = logic.dataList!; + return pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onLoading: (ctr) => logic.loadData(isRefresh: false), + enablePullDown: false, + child: GridView.builder( + physics: const ClampingScrollPhysics(), + padding: widget.padding, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: widget.childAspectRatio, + ), + itemCount: list.length, + itemBuilder: (_, index) => widget.mediaStyle == MediaStyle.Video + ? _videoCell(list[index]) + : _acgCell(list[index]), + ), + ); + }, + ); + } + + // 视频推荐 cell:不传 onVideoTap 走 cell 默认 push,详情页传入则当前页切播放源 + Widget _videoCell(VideoModel model) { + return VideoSimpleCell( + videoModel: model, + onTap: widget.onVideoTap == null ? null : () => widget.onVideoTap!(model), + ); + } + + // 漫画/动漫推荐 cell:不传 onAcgTap 时默认跳详情页 + Widget _acgCell(CartoonMediaInfo model) { + return AcgItemWidget( + info: model, + tapCallback: widget.onAcgTap == null + ? () { + widget.tapAction?.call(); + pushToCartoonPage(model); + } + : () => widget.onAcgTap!(model), + ); + } +} diff --git a/lib/hj_page/cartoon/cartoon_sectionlist_page.dart b/lib/hj_page/cartoon/cartoon_sectionlist_page.dart new file mode 100644 index 0000000..72ba570 --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_sectionlist_page.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import 'cartoon_sectionlist_sub_page.dart'; + +//漫画专题列表的排序 tab(标题与排序值绑定) +const _sortTabs = [ + SortTab('最多收藏', 3), + SortTab('最新上架', 1), + SortTab('最多观看', 2), +]; + +//专题更多 +class CartoonSectionListPage extends StatefulWidget { + final String? sectionID; + final String? tagName; + + const CartoonSectionListPage({ + super.key, + this.sectionID, + this.tagName, + }); + + @override + State createState() => _CartoonSectionListPageState(); +} + +class _CartoonSectionListPageState extends State + with SingleTickerProviderStateMixin { + late final TabController tabCtr = + TabController(length: _sortTabs.length, vsync: this); + + @override + void dispose() { + tabCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.tagName ?? '')), + body: Column( + children: [ + _buildTabs(), + Expanded( + child: TabBarView( + controller: tabCtr, + children: List.generate( + _sortTabs.length, + (i) => CartoonSectionSubPage( + sortType: _sortTabs[i].sort, + sectionID: widget.sectionID, + ).keepAlive, + ), + ), + ) + ], + ), + ); + } + + Widget _buildTabs() { + return Container( + padding: const EdgeInsets.only(bottom: 3), + alignment: Alignment.center, + child: TabBar( + tabAlignment: TabAlignment.center, + controller: tabCtr, + tabs: List.generate( + _sortTabs.length, + (index) => Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + border: index != _sortTabs.length - 1 + ? Border( + right: BorderSide( + width: .5, color: Colors.white.withValues(alpha: .1)), + ) + : null, + ), + child: Text(_sortTabs[index].name), + ), + ), + labelColor: Colors.white.withValues(alpha: .9), + labelPadding: EdgeInsets.zero, + labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + unselectedLabelColor: Colors.white.withValues(alpha: 0.5), + unselectedLabelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + indicator: const BoxDecoration(), + ), + ); + } +} diff --git a/lib/hj_page/cartoon/cartoon_sectionlist_sub_logic.dart b/lib/hj_page/cartoon/cartoon_sectionlist_sub_logic.dart new file mode 100644 index 0000000..4e8d5f3 --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_sectionlist_sub_logic.dart @@ -0,0 +1,31 @@ +import 'package:hgdj/hj_model/cartoon_media_info.dart'; +import 'package:hgdj/hj_utils/api_service/acg_service.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +class CartoonSectionSubLogic extends ListBaseLogic { + final int? sortType; + final String? sectionID; + + CartoonSectionSubLogic({this.sortType, this.sectionID}); + + @override + void onReady() { + super.onReady(); + loadData(); + } + + void loadData() => fetchData(isRefresh: true, fetch: _fetch); + + void loadMoreData() => fetchData(isRefresh: false, fetch: _fetch); + + Future<(List?, bool)> _fetch(int page) async { + final resp = await ACGService.getPreferences( + page, + 12, + sortType: sortType, + sId: sectionID ?? '', + type: 0, + ); + return (resp?.list, resp?.hasNext == true); + } +} diff --git a/lib/hj_page/cartoon/cartoon_sectionlist_sub_page.dart b/lib/hj_page/cartoon/cartoon_sectionlist_sub_page.dart new file mode 100644 index 0000000..c96fee0 --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_sectionlist_sub_page.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import 'acg_widget_item.dart'; +import 'cartoon_sectionlist_sub_logic.dart'; + +class CartoonSectionSubPage extends StatelessWidget { + final int? sortType; + final String? sectionID; + + const CartoonSectionSubPage({super.key, this.sectionID, this.sortType}); + + //按排序值隔离三个 tab 的多实例 + String get _tag => (sortType ?? 0).toString(); + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: _tag, + init: CartoonSectionSubLogic(sortType: sortType, sectionID: sectionID), + builder: (logic) { + if (logic.isLoading) return LoadingCenterWidget(); + return pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onRefresh: (ctr) => logic.loadData(), + onLoading: (ctr) => logic.loadMoreData(), + child: logic.isEmptyData ? CErrorWidget() : _buildGrid(logic), + ); + }, + ); + } + + Widget _buildGrid(CartoonSectionSubLogic logic) { + return GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 12, + crossAxisSpacing: 5, + childAspectRatio: 111 / 190, + ), + itemCount: logic.dataList?.length ?? 0, + itemBuilder: (context, index) => + AcgItemWidget(info: logic.dataList?[index]), + ); + } +} diff --git a/lib/hj_page/cartoon/cartoon_sub_detail_page.dart b/lib/hj_page/cartoon/cartoon_sub_detail_page.dart new file mode 100644 index 0000000..1f75b6e --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_sub_detail_page.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/home/plate_model.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import 'cartoon_sub_grid_page.dart'; + +class CartoonSubDetailPage extends StatelessWidget { + final ModuleData? tagData; + final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说 + final ScrollController? scroll; + + const CartoonSubDetailPage({ + super.key, + this.tagData, + this.type = MediaStyle.Pic, + this.scroll, + }); + + @override + Widget build(BuildContext context) { + return CartoonSubGridPage(tagData: tagData, type: type, scrollCtr: scroll) + .keepAlive; + } +} diff --git a/lib/hj_page/cartoon/cartoon_sub_grid_logic.dart b/lib/hj_page/cartoon/cartoon_sub_grid_logic.dart new file mode 100644 index 0000000..b39d6b7 --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_sub_grid_logic.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/home/module_detail_model.dart'; +import 'package:hgdj/hj_model/home/plate_model.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +class CartoonSubGridLogic extends ListBaseLogic { + final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说 + final ModuleData? tagData; //模块model + List> sortTabs = []; //排序 tab:标题与 sort 值绑定 + List specials = []; //动漫和漫画 添加标签 + late TabController tabCtr; + + int get crossAxisCount { + if (type == MediaStyle.Cartoon) return 3; + if (type == MediaStyle.Pic) return 3; + if (type == MediaStyle.Game) return 2; + if (type == MediaStyle.Novel) return 3; + return 0; + } + + double get childAspectRatio { + if (type == MediaStyle.Cartoon) return 111 / 190; + if (type == MediaStyle.Pic) return 168 / 246; + if (type == MediaStyle.Game) return 168 / 137; + if (type == MediaStyle.Novel) return 111 / 190; + return 1; + } + + String get newsType { + if (tagData?.moduleName == '热门推荐' && tagData?.type == -100) return 'PIC'; + if (tagData?.moduleName == '热门推荐' && tagData?.type == -101) + return 'SEED_LINK'; + return ''; + } + + bool get isAcg => + type == MediaStyle.Novel || type == MediaStyle.Cartoon; //是否是acg模块 + + CartoonSubGridLogic({this.tagData, this.type = MediaStyle.Pic}); + + @override + void onClose() { + tabCtr.dispose(); + super.onClose(); + } + + @override + onInit() { + super.onInit(); + if (type == MediaStyle.Cartoon) { + //动漫:排序 tab 可能来自后端,没配就用这套默认 + sortTabs = tagData?.sortTabs ?? + const [ + SortTab('热门推荐', 2), + SortTab('最新上架', 1), + SortTab('最新热评', 9), + SortTab('最多收藏', 7), + ]; + } else if (type == MediaStyle.Pic || type == MediaStyle.Novel) { + sortTabs = const [ + SortTab('最多观看', 3), + SortTab('最新发布', 1), + SortTab('最多收藏', 7) + ]; + } else { + sortTabs = const [SortTab('最新', 1), SortTab('最热', 2), SortTab('畅销', 8)]; + } + tabCtr = TabController(length: sortTabs.length, vsync: this); + } + + @override + onReady() { + super.onReady(); + loadData(); + } + + //获取海角样式数据 + loadData({int pageNum = 1}) async { + dataList ??= []; + ModuleDetailModel? retModel; + if (tagData?.moduleName == '热门推荐' && + (tagData?.type == -100 || tagData?.type == -101)) { + //图集 + retModel = await VidService.communityRecommend( + pageNum, + pageSize: type == MediaStyle.Pic ? 12 : 10, //色图一行3个,按12加载凑整4行;黄游保持10 + newsType: newsType, + sortType: sortTabs[tabCtr.index].sort, + ); + if (retModel != null) { + if (pageNum == 1) dataList?.clear(); + currentPage = pageNum; + dataList?.addAll(retModel.allVideoInfo ?? []); //设置海角样式列表 + } + } else { + retModel = await VidService.getModuleDetail( + tagData?.id ?? "", + pageNumber: pageNum, + pageSize: 12, + moduleSort: sortTabs[tabCtr.index].sort, + ); + + if (retModel != null) { + currentPage = pageNum; + if (pageNum == 1) { + dataList?.clear(); + specials.clear(); + specials.addAll(retModel.allSection ?? []); + } + if (isAcg) { + dataList?.addAll(retModel.allMediaInfo ?? []); //设置海角样式列表 + } else { + dataList?.addAll(retModel.allVideoInfo ?? []); //设置海角样式列表 + } + } + } + update(); + refreshCtr?.refreshCompleted(); + retModel?.hasNext == true + ? refreshCtr?.loadComplete() + : refreshCtr?.loadNoData(); + } + + loadMoreData() => loadData(pageNum: currentPage + 1); + + //切换排序 tab:先清空列表显示 loading,延迟一帧再按新 sort 拉第一页(避免请求卡住切换动画) + void onChangeIndexAction() { + dataList = null; + update(); + Future.delayed(const Duration(milliseconds: 50), () => loadData()); + } +} diff --git a/lib/hj_page/cartoon/cartoon_sub_grid_page.dart b/lib/hj_page/cartoon/cartoon_sub_grid_page.dart new file mode 100644 index 0000000..9edcb39 --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_sub_grid_page.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_model/home/plate_model.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/sliver_delegate.dart'; +import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../home/home_cell_style/quick_entry_row.dart'; +import '../home/home_cell_style/video_simple_cell.dart'; +import '../home/home_sub_module/widget/special_topics_view.dart'; +import 'acg_widget_item.dart'; +import 'cartoon_sub_grid_logic.dart'; +import 'photo_gallery_item.dart'; + +//海角样式:漫画/图集/黄游/小说共用的网格列表,item 样式与列数按 [type] 切 +class CartoonSubGridPage extends StatelessWidget { + final ModuleData? tagData; //模块model + final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说 + final ScrollController? scrollCtr; + + const CartoonSubGridPage({ + super.key, + this.tagData, + this.type = MediaStyle.Pic, + this.scrollCtr, + }); + + // 多 type/模块 tab 经 keepAlive 并存,按 id+type 隔离各自的 logic + String get _tag => '${tagData?.id ?? ''}$type'; + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: _tag, + init: CartoonSubGridLogic(tagData: tagData, type: type), + builder: (logic) => pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onRefresh: (ctr) => logic.loadData(), + onLoading: (ctr) => logic.loadMoreData(), + child: _buildContent(logic), + ), + ); + } + + Widget _buildContent(CartoonSubGridLogic logic) { + return CustomScrollView( + controller: scrollCtr, + slivers: [ + if (tagData?.pureVersion != true) + SliverToBoxAdapter( + child: AdsGridViewWidget( + type == MediaStyle.Cartoon ? 4 : 7, //广告位标识 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + accordingAdsType: true, + ), + ), + // 这两块只有动漫/漫画有 + if (type == MediaStyle.Cartoon) ...[ + SliverToBoxAdapter(child: QuickEntryRow(tagData)), + SliverToBoxAdapter( + child: SpecialTopicsView( + logic.specials, + module: tagData, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + ), + ), + ], + _buildSortType(logic), + _buildGridList(logic), + ], + ); + } + + //排序 + Widget _buildSortType(CartoonSubGridLogic logic) { + return SliverPersistentHeader( + pinned: true, + delegate: MySliverDelegate( + maxHeight: 36, + minHeight: 36, + child: Container( + alignment: Alignment.center, + color: AppColors.primaryColor, + child: TabBar( + tabAlignment: TabAlignment.center, + controller: logic.tabCtr, + isScrollable: true, + //选中/未选中只差颜色,unselectedLabelStyle 不传会 fallback 到 labelStyle + labelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + labelColor: Colors.white.withValues(alpha: .9), + unselectedLabelColor: Colors.white.withValues(alpha: .55), + indicator: const BoxDecoration(), //去掉默认下划线 + onTap: (_) => logic.onChangeIndexAction(), + tabs: logic.sortTabs + .map((e) => Padding( + padding: const EdgeInsets.only(top: 12, bottom: 4), + child: Text(e.name), + )) + .toList(), + ), + ), + ), + ); + } + + Widget _buildGridList(CartoonSubGridLogic logic) { + final list = logic.dataList; + //null 是还没拉过,空 list 才是真没数据 + if (list == null) { + return const SliverToBoxAdapter( + child: SizedBox(height: 400, child: LoadingCenterWidget())); + } + if (list.isEmpty) { + return SliverToBoxAdapter( + child: SizedBox( + height: 400, child: CErrorWidget(retryOnTap: logic.loadData)), + ); + } + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: logic.crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 5, + childAspectRatio: logic.childAspectRatio, + ), + delegate: SliverChildBuilderDelegate( + //-1动漫/漫画 0-图集 1-黄游 2-小说 + (_, index) => switch (type) { + MediaStyle.Cartoon || + MediaStyle.Novel => + AcgItemWidget(info: list[index], coverV: true), + MediaStyle.Pic => + PhotoGalleryItem(videoModel: list[index], textline: 1), + MediaStyle.Game => VideoSimpleCell( + videoModel: list[index], + isFromHY: true, + textLines: 1, + ), + _ => const SizedBox.shrink(), + }, + childCount: list.length, + ), + ), + ); + } +} diff --git a/lib/hj_page/cartoon/cartoon_sub_module_logic.dart b/lib/hj_page/cartoon/cartoon_sub_module_logic.dart new file mode 100644 index 0000000..c84a5d7 --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_sub_module_logic.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_model/home/plate_model.dart'; +import 'package:hgdj/hj_page/main_page/provider/bottom_bar_provider.dart'; +import 'package:hgdj/hj_utils/const.dart'; + +class CartoonSubModuleLogic extends GetxController + with GetTickerProviderStateMixin { + final int tabIndex; // 未初始化时,内链跳转需要 + final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说 + List tabs = []; //漫画 + late TabController tabController; + + CartoonSubModuleLogic(this.tabIndex, {this.type = MediaStyle.Pic}); + + @override + void onInit() { + if (type == MediaStyle.Pic) { + //0-图集 showtype = 2,列表 + tabs.addAll(Config.plateModule?.picsUi ?? []); + } else if (type == MediaStyle.Game) { + //1-黄游 showtype = 2,列表 + tabs.addAll(Config.plateModule?.gameUi ?? []); + } else if (type == MediaStyle.Novel) { + //2-小说 + tabs.addAll(Config.plateModule?.novel ?? []); + } + tabController = TabController( + initialIndex: tabIndex, + length: tabs.length, + vsync: this, + ); + tabController.addListener(() { + if (tabController.indexIsChanging) return; + CommunityBottomProvider().subIndex = tabController.index; + }); + super.onInit(); + } + + @override + void onClose() { + tabController + .dispose(); // 自建 TabController,随 logic 释放(含内部 AnimationController + 匿名监听) + super.onClose(); + } + + void skipToTabIndex(int index) { + tabController.index = index; + } +} diff --git a/lib/hj_page/cartoon/cartoon_sub_module_page.dart b/lib/hj_page/cartoon/cartoon_sub_module_page.dart new file mode 100644 index 0000000..0266372 --- /dev/null +++ b/lib/hj_page/cartoon/cartoon_sub_module_page.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/main_page/provider/bottom_bar_provider.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; + +import 'cartoon_sub_detail_page.dart'; +import 'cartoon_sub_module_logic.dart'; + +//漫画亚模块 +class CartoonSubModulePage extends StatelessWidget { + final MediaStyle type; //-1:动漫和漫画 0:图集 1:黄游 2:小说 + final int tabIndex; + + const CartoonSubModulePage( + {super.key, this.type = MediaStyle.Pic, this.tabIndex = 0}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: CartoonSubModuleLogic(tabIndex, type: type), + tag: type.name, + builder: (logic) => Column( + children: [ + _buildTabbar(logic), + Expanded( + child: TabBarView( + controller: logic.tabController, + children: List.generate( + logic.tabs.length, + (index) => CartoonSubDetailPage( + tagData: logic.tabs[index], + type: type, + scroll: CommunityBottomProvider().scrollCtr( + type == MediaStyle.Pic + ? MediaStyle.Pic + : MediaStyle.Novel, + index), + ), + ), + ), + ) + ], + ), + ); + } + + Widget _buildTabbar(CartoonSubModuleLogic logic) { + return TabBar( + controller: logic.tabController, + padding: const EdgeInsets.symmetric(horizontal: 8), + tabs: List.generate( + logic.tabs.length, + (index) => Padding( + padding: const EdgeInsets.only(top: 6, bottom: 8), + child: Text(logic.tabs[index].moduleName ?? ''), + ), + ), + labelPadding: const EdgeInsets.symmetric(horizontal: 8), + tabAlignment: TabAlignment.start, + isScrollable: true, + labelColor: Colors.white.withValues(alpha: .9), + labelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + unselectedLabelColor: Colors.white.withValues(alpha: .55), + unselectedLabelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + indicator: CustomIndicator(offsetY: 6), + ); + } +} diff --git a/lib/hj_page/cartoon/novel_detail_logic.dart b/lib/hj_page/cartoon/novel_detail_logic.dart new file mode 100644 index 0000000..981f259 --- /dev/null +++ b/lib/hj_page/cartoon/novel_detail_logic.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../hj_model/cartoon_media_info.dart'; +import '../../hj_utils/api_service/acg_service.dart'; +import '../../hj_utils/history_util.dart'; +import '../../routers/jump_router.dart'; +import 'audio_player_bottomsheet.dart'; +import 'text_novel_read_page.dart'; +import 'voice_novel_read_page.dart'; +import 'widget/acg_source_manager.dart'; + +class NovelDetailLogic extends GetxController { + String id; + CartoonMediaInfo? model; + final recommends = []; + int page = 1; + RefreshController? refreshCtr; + + bool showHeader = true; + double showOffset = 100; + + late ACGSourceManager manager; + late ScrollController scrollCtr = ScrollController() + ..addListener(() { + if (scrollCtr.offset <= showOffset) { + if (!showHeader) { + showHeader = true; + update(['header']); + } + } else { + if (showHeader) { + showHeader = false; + update(['header']); + } + } + }); + NovelDetailLogic(this.id) : manager = ACGSourceManager(id); + + @override + void onReady() async { + super.onReady(); + // 先拿详情(含 freeEpisode),再拉子集,才能正确标记前 N 集免费 + await fetchDetailData(); + update(); + manager.fetchAllEpisodes(); + fetchRecommendData(); + } + + @override + void onClose() { + scrollCtr.dispose(); //修复泄漏 + // manager 由页面的 ChangeNotifierProvider(create:) 在卸载时 dispose,本类不再重复释放 + // refreshCtr 由 CustomRefreshView 创建并 dispose,本类只持引用,绝不能再 dispose + super.onClose(); + } + + Future fetchDetailData() async { + final result = await ACGService.getMediaInfo(id); + if (result != null) { + model = result; + manager.mediaInfo = result; + HistoryUtil.insert(model!, MediaStyle.Novel); + } else { + model = CartoonMediaInfo(); + } + } + + // 获取相关推荐 + Future fetchRecommendData() async { + final result = await ACGService.comicsRecommendList(page, 12, + tagId: model?.tagDetails?.firstOrNull?.id ?? '', mediaType: 'text'); + if (result != null) { + page += 1; + recommends.addAll(result.list ?? []); + } + result?.hasNext ?? false + ? refreshCtr?.loadComplete() + : refreshCtr?.loadNoData(); + update(); + } + + // 打开所有章节 + episodesBottomSheet() async { + final result = await Get.bottomSheet(AudioPlayerBottomSheet( + model?.title ?? '', + allAudiobooks: manager.allEpisodes, + currentPlayIndex: manager.index, + unit: model?.unit, + hasPermission: model?.hasPermission ?? false, + )); + if (result != null) { + gotoPlay(index: result as int); + } + } + + gotoPlay({int index = 0}) async { + final mediaSubType = model?.mediaSubType ?? -1; + final opm = await manager + .getEpisodeHasPermisson(index); //通过时内部已切集,不必再 changeEpisodesIndex + if (opm != null) { + //0-默认文本小说 1-有声小说 + if (mediaSubType == 0) { + Get.to(() => TextNovelReadPage(manager: manager), + preventDuplicates: false); + } else if (mediaSubType == 1) { + Get.to( + () => VoiceNovelReadPage( + manager: manager, + id: id, + index: index, + title: model?.title ?? '', + ), + opaque: false, + preventDuplicates: false); + } + } + } + + // 跳转 + jumpToOtherPage(CartoonMediaInfo info) { + if (model?.id == info.id) { + showToast('您当前正在观看此媒体'); + return; + } + pushToCartoonPage(info); + } +} diff --git a/lib/hj_page/cartoon/novel_detail_page.dart b/lib/hj_page/cartoon/novel_detail_page.dart new file mode 100644 index 0000000..94c3cd8 --- /dev/null +++ b/lib/hj_page/cartoon/novel_detail_page.dart @@ -0,0 +1,293 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/follow_button.dart'; +import 'package:provider/provider.dart'; + +import '../../hj_utils/widget_util.dart'; +import '../../tools_base/loading/loading_center_widget.dart'; +import '../../tools_base/refresh/pull_refresh.dart'; +import 'acg_widget_item.dart'; +import 'novel_detail_logic.dart'; +import 'novel_header.dart'; +import 'widget/acg_source_manager.dart'; +import 'widget/free_badge.dart'; + +//小说详情页 - +class NovelDetailPage extends StatefulWidget { + final String? id; + + const NovelDetailPage(this.id, {super.key}); + + @override + State createState() => _NovelDetailPageState(); +} + +// 详情页可经「推荐 → 再开详情页」成环并存,用 UniqueTagMixin 给每个实例唯一 tag +class _NovelDetailPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: NovelDetailLogic(widget.id ?? ''), + builder: (_) => Scaffold( + body: () { + if (_.model == null) return LoadingCenterWidget(); + if (_.model?.id == null) return CErrorWidget(); + // 用 create: 让 Provider 在页面卸载时自动 dispose manager(释放音频播放器+订阅),Flutter 层保证释放 + return ChangeNotifierProvider( + create: (ctx) => _.manager, + child: _buildContent(_), + ); + }(), + ), + ); + } + + _buildContent(NovelDetailLogic _) { + return Stack( + alignment: Alignment.center, + children: [ + Column( + children: [ + Expanded( + child: pullYsRefresh( + onInit: (ctr) => _.refreshCtr = ctr, + enablePullDown: false, + onLoading: (ctr) => _.fetchRecommendData(), + child: CustomScrollView( + controller: _.scrollCtr, + slivers: [ + SliverToBoxAdapter(child: _buildNovelHeader(_)), + 18.sliverSizeBoxH, + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + '相关推荐', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + 12.sliverSizeBoxH, + if (_.recommends.isNotEmpty) ...[ + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverPadding( + padding: EdgeInsets.only(bottom: 80), + sliver: SliverGrid.builder( + itemCount: _.recommends.length, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: 115 / 192, + ), + itemBuilder: (ctx, index) { + final model = _.recommends[index]; + return AcgItemWidget( + info: _.recommends[index], + tapCallback: () => _.jumpToOtherPage(model), + ); + }), + ), + ), + ] else ...[ + SliverToBoxAdapter( + child: CErrorWidget(), + ) + ], + 50.sliverSizeBoxH + ], + ), + ), + ), + InkWell( + enableFeedback: false, + child: Container( + margin: EdgeInsets.symmetric(horizontal: 16, vertical: 10), + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: Consumer( + builder: (context, value, child) => Text( + _.manager.actionTitle(), + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + )), + onTap: () => _.gotoPlay(index: _.manager.index), + ), + ], + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: _buildTopHeader(_), + ) + ], + ); + } + + _buildTopHeader(NovelDetailLogic _) { + return GetBuilder( + tag: uniqueTag, + id: 'header', + builder: (_) { + return Container( + padding: EdgeInsets.only(left: 16, right: 16, top: screen.paddingTop), + height: 44 + screen.paddingTop, + color: !_.showHeader ? Color(0xff0E141E) : Colors.transparent, + child: Row( + children: [ + InkWell( + enableFeedback: false, + child: Image.asset('acg_noval_back.png'.acgImgPath, width: 24), + onTap: () => Get.back(), + ), + 40.sizeBoxW, + Spacer(), + Text( + !_.showHeader ? _.model?.title ?? '' : '', + style: textStyle(16, Colors.white, FontWeight.w500), + ), + Spacer(), + Consumer( + builder: (context, manager, child) { + bool col = + manager.mediaInfo?.mediaStatus?.hasCollected ?? false; + return FollowButton( + mediaId: manager.mediaInfo?.id ?? '', + isFollow: col, + followType: FollowEnum.noval, + successsAction: (isSuccess) { + manager.updateCollectState(isSuccess); + if (isSuccess) { + showToast('收藏成功'); + } + }, + ); + }, + ) + ], + ), + ); + }, + ); + } + + _buildNovelHeader(NovelDetailLogic _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NovelHeader(manager: _.manager), + 18.sizeBoxH, + Consumer( + builder: (ctx, manager, child) => Column( + children: [ + 18.sizeBoxH, + Row( + children: [ + Text( + '共${_.model?.totalEpisode ?? 0}${_.model?.unit}', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + Spacer(), + InkWell( + enableFeedback: false, + onTap: () => _.episodesBottomSheet(), + child: Row( + children: [ + Text( + '目录', + style: TextStyle( + color: Color(0xffA7A7A7), + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + 2.sizeBoxW, + Image.asset( + 'arrow_right_grey.webp'.commonImgPath, + color: Color(0xff989898), + width: 18, + ) + ], + )), + ], + ), + 6.sizeBoxH, + ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: NeverScrollableScrollPhysics(), + itemCount: min(manager.allEpisodes.length, 4), + separatorBuilder: (context, index) => Divider( + height: .5, + color: Colors.white.withValues(alpha: .05), + ), + itemBuilder: (BuildContext context, int index) { + final epm = manager.allEpisodes[index]; + return InkWell( + enableFeedback: false, + onTap: () => _.gotoPlay(index: index), + child: Column( + children: [ + 12.sizeBoxH, + Row( + children: [ + Text( + '第${epm.episodeNumber ?? 1}${_.model?.unit}', + style: TextStyle( + color: manager.index == index + ? AppColors.actionRed + : Color(0xff989898), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + //无任何权限时,前 N 集免费展示「免费」角标 + if (manager.mediaInfo?.hasPermission == false && + epm.inFreeEpisode) ...[ + 6.sizeBoxW, + const FreeBadge(), + ], + Spacer(), + Icon(Icons.navigate_next_sharp, + size: 20, color: Colors.white) + ], + ), + 12.sizeBoxH, + ], + ), + ); + }, + ), + ], + ), + ).paddingSymmetric(horizontal: 16), + ], + ); + } +} diff --git a/lib/hj_page/cartoon/novel_header.dart b/lib/hj_page/cartoon/novel_header.dart new file mode 100644 index 0000000..2a21f5e --- /dev/null +++ b/lib/hj_page/cartoon/novel_header.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../hj_model/cartoon_media_info.dart'; +import '../../hj_model/video_model.dart'; +import '../../hj_utils/widget_util.dart'; +import '../../tools_base/widget/shrink_wrap.dart'; +import '../home/tag/cartoon_tag_page.dart'; +import 'widget/acg_expand_text.dart'; +import 'widget/acg_source_manager.dart'; + +class NovelHeader extends StatelessWidget { + final ACGSourceManager? manager; + const NovelHeader({super.key, this.manager}); + + @override + Widget build(BuildContext context) { + final info = manager?.mediaInfo; + return Stack( + children: [ + Positioned( + left: 0, + top: 0, + right: 0, + child: NetworkImageLoader(imageUrl: info?.coverH ?? ''), + ), + Positioned.fill( + child: Container(color: Colors.black.withValues(alpha: .7)), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + (screen.paddingTop + 72).sizeBoxH, + _titleRow(info), + 18.sizeBoxH, + if (info?.summary?.isNotEmpty == true) ...[ + 12.sizeBoxH, + AcgShowMoreTextWidget( + summary: info?.summary ?? '', + maxWidth: Get.width - 32, + ), + ], + 20.sizeBoxH, + ], + ), + ), + ], + ); + } + + Widget _titleRow(CartoonMediaInfo? info) { + return Row( + children: [ + Stack( + children: [ + NetworkImageLoader( + imageUrl: info?.coverH ?? '', + width: 111, + height: 148, + ), + if (info?.isAudiobooks == true) + Positioned( + top: 6, + left: 6, + child: Image.asset('noval_sign.png'.acgImgPath, width: 20), + ), + ], + ), + 12.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info?.title ?? '', + style: textStyle(16, Colors.white, FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + 6.sizeBoxH, + Row( + children: [ + Text( + '共${info?.totalEpisode ?? 0}${info?.unit}', + style: + textStyle(12, const Color(0xff999999), FontWeight.w400), + ), + 8.sizeBoxW, + Text( + '/', + style: + textStyle(12, const Color(0xff999999), FontWeight.w400), + ), + 8.sizeBoxW, + Text( + info?.updateDesc ?? '', + style: textStyle( + 12, info!.getDetailUpdateColor, FontWeight.w400), + ), + ], + ), + if (info.tagDetails?.isNotEmpty == true) ...[ + 6.sizeBoxH, + _tags(info.tagDetails!), + ], + ], + ), + ), + ], + ); + } + + Widget _tags(List tags) { + return ShrinkWrap( + spacing: 10, + runSpacing: 10, + maxLines: 1, + children: tags + .map((tag) => InkWell( + enableFeedback: false, + onTap: () => Get.to( + CartoonTagPage(title: tag.name ?? '', sId: tag.id ?? '')), + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xff22252D), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '#${tag.name ?? ''}', + maxLines: 1, + style: + const TextStyle(color: Color(0xFF999999), fontSize: 12), + ), + ), + )) + .toList(), + ); + } +} diff --git a/lib/hj_page/cartoon/photo_gallery_item.dart b/lib/hj_page/cartoon/photo_gallery_item.dart new file mode 100644 index 0000000..2f9f345 --- /dev/null +++ b/lib/hj_page/cartoon/photo_gallery_item.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/find/pic_collect/pics_detail_page.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +//图集item +class PhotoGalleryItem extends StatelessWidget { + final VideoModel? videoModel; + final Function? callback; + final int textline; + + const PhotoGalleryItem({ + super.key, + this.videoModel, + this.callback, + this.textline = 1, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: () { + if (callback != null) { + callback?.call(); + } else { + Get.to(PicsDetailPage(id: videoModel?.id), preventDuplicates: false); + } + }, + child: Column( + children: [ + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + NetworkImageLoader( + imageUrl: videoModel?.cover ?? "", + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black.withValues(alpha: 0), + Colors.black.withValues(alpha: .6), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + )), + height: 30, + child: Row( + children: [ + 8.sizeBoxW, + Image.asset("eye_white.webp".commonImgPath, width: 16), + SizedBox(width: 2), + Text( + videoModel?.playCount?.countStr ?? "", + style: TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + Spacer(), + Image.asset("tuji_icon.webp".communityPath, width: 16), + 2.sizeBoxW, + Text( + '${videoModel?.seriesCover?.length ?? 0}', + textAlign: TextAlign.justify, + style: TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + 8.sizeBoxW, + ], + ), + ), + ) + ], + ), + ), + 4.sizeBoxH, + Row( + children: [ + Expanded( + child: Text( + '${videoModel?.title}', + maxLines: textline, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 12, + ), + ), + ) + ], + ) + ], + ), + ); + } +} diff --git a/lib/hj_page/cartoon/text_novel_read_logic.dart b/lib/hj_page/cartoon/text_novel_read_logic.dart new file mode 100644 index 0000000..189577e --- /dev/null +++ b/lib/hj_page/cartoon/text_novel_read_logic.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/media_content.dart'; +import 'package:hgdj/hj_utils/api_service/acg_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../../hj_model/acg/comic_chapters_model.dart'; +import '../../hj_model/cartoon_media_info.dart'; +import '../../tools_base/loading/loading_helper.dart'; +import 'widget/acg_source_manager.dart'; + +// 文字小说阅读 logic +class TextNovelReadLogic extends GetxController { + final ACGSourceManager manager; + + CartoonMediaInfo? mediaInfo; + MediaContent? detailInfo; // 当前子集详情 + List allEpisodes = []; + int currentEpisode = 0; // 当前子集数,从 0 开始 + int episodeIndex = 1; + bool showMenu = true; + bool isDark = true; + bool isAscend = true; // 排序类型 true-正序 false-倒序 + int durationTime = 250; + + final ScrollController scrollCtr = ScrollController(); + + TextNovelReadLogic(this.manager); + + @override + void onInit() { + super.onInit(); + mediaInfo = manager.mediaInfo; + currentEpisode = manager.index; + } + + @override + void onReady() { + super.onReady(); + allEpisodes.addAll(manager.allEpisodes); // 复制,便于反转 + loadData(); + } + + @override + void onClose() { + scrollCtr.dispose(); + super.onClose(); + } + + // ========== 公开方法 ========== + // 获取文字小说。返回 Future 不是可有可无:CErrorWidget 靠它做重试 loading 和防连点 + Future loadData( + {bool showLoading = false, bool needToTop = false}) async { + if (showLoading) LoadingHelper.showLoading(); + final model = await manager.getComicsMediaContent(index: currentEpisode); + if (showLoading) LoadingHelper.dismissLoading(); + // 权限已在 getComicsMediaContent → getEpisodeHasPermisson 里判过(含前 N 集免费),直接展示 + _setData(model); + update(); + if (needToTop) _toTop(); + } + + // true 下一集 / false 上一集 + void loadOtherEpisode(bool next, {bool showLoading = false}) async { + final index = next ? currentEpisode + 1 : currentEpisode - 1; + if (index < 0) { + showToast('这是第一${mediaInfo?.unit}了喔~'); + return; + } + if (index > manager.allEpisodes.length - 1) { + showToast('这是最后一${mediaInfo?.unit}了喔~'); + return; + } + final model = await manager.getComicsMediaContent(index: index); + if (model == null) return; + currentEpisode = index; + _setData(model); + update(); + _toTop(); + } + + void loadDataWithIndex(String id) async { + // 目录列表可能倒序,按 id 定位 manager 中真实下标(不依赖"集号==下标+1",也天然不会越界) + final realIndex = manager.allEpisodes.indexWhere((e) => e.id == id); + if (realIndex < 0) { + showToast('数据错误,请联系客服'); + return; + } + // 目录切集也要过权限:非免费/未解锁章节先弹 VIP/购买弹窗,通过后(内部已 changeEpisodesIndex)再拉详情 + final epm = await manager.getEpisodeHasPermisson(realIndex); + if (epm == null) return; + LoadingHelper.showLoading(); + final model = + await ACGService.getMediaDetail(mediaId: manager.mediaInfo?.id, id: id); + LoadingHelper.dismissLoading(); + if (model != null) { + currentEpisode = realIndex; + _setData(model); + } + update(); + } + + // 展示菜单 + void onShowMenuAction() { + showMenu = !showMenu; + update(); + } + + // 切换暗黑模式 + void changeDarkStyle() { + isDark = !isDark; + update(); + } + + // 排序 + void onChangeSortAction() { + isAscend = !isAscend; + allEpisodes = allEpisodes.reversed.toList(); + update(['list', 'sort']); + } + + // ========== 私有方法 ========== + void _setData(MediaContent? model) { + if (model != null) { + detailInfo = model; + manager.changeEpisodesIndex((model.episodeNumber ?? 1) - 1); + update(); + } else { + detailInfo = MediaContent(); + } + } + + // 回到顶部 + void _toTop() { + Future.delayed(const Duration(milliseconds: 100)).then((_) { + scrollCtr.jumpTo(0); + }); + } +} diff --git a/lib/hj_page/cartoon/text_novel_read_page.dart b/lib/hj_page/cartoon/text_novel_read_page.dart new file mode 100644 index 0000000..d368219 --- /dev/null +++ b/lib/hj_page/cartoon/text_novel_read_page.dart @@ -0,0 +1,321 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/stagger_in_item.dart'; + +import '../../tools_base/loading/loading_center_widget.dart'; +import 'text_novel_read_logic.dart'; +import 'widget/acg_source_manager.dart'; + +// 文字小说阅读页 +class TextNovelReadPage extends StatelessWidget { + final ACGSourceManager manager; + const TextNovelReadPage({super.key, required this.manager}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: TextNovelReadLogic(manager), + builder: (logic) => Scaffold( + backgroundColor: logic.isDark ? const Color(0xff0F0F0F) : Colors.white, + body: _buildBody(logic), + endDrawer: _buildEndDrawer(logic), + ), + ); + } + + Widget _buildBody(TextNovelReadLogic logic) { + final detail = logic.detailInfo; + if (detail == null) return LoadingCenterWidget(); + if (detail.id == null) return CErrorWidget(retryOnTap: logic.loadData); + //Builder 是为了拿到 Scaffold 之下的 context——header 的目录按钮要用它 openEndDrawer + return Builder( + builder: (ctx) => Stack( + fit: StackFit.expand, + children: [ + _buildContent(logic), + _buildHeader(ctx, logic), + _buildFooter(logic), + ], + ), + ); + } + + // 正文,点击任意处切换菜单显隐 + Widget _buildContent(TextNovelReadLogic logic) { + return InkWell( + enableFeedback: false, + onTap: logic.onShowMenuAction, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${logic.mediaInfo?.title ?? ''}/${logic.detailInfo?.name ?? ''}', + style: const TextStyle(fontSize: 16, color: Color(0xff525252)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + 16.sizeBoxH, + Expanded( + child: SingleChildScrollView( + controller: logic.scrollCtr, + padding: + const EdgeInsets.only(bottom: 60), //给底部菜单栏留位,别被挡住最后几行 + child: Align( + //正文不足一行时居中(长文自然撑满,看不出差别),与旧版 Column 默认的 center 保持一致 + alignment: Alignment.topCenter, + child: Text( + logic.detailInfo?.text ?? '', + style: TextStyle( + fontSize: 16, + color: (logic.isDark ? Colors.white : Colors.black) + .withValues(alpha: .9), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + // 顶部菜单栏,收起时整条移出屏幕上方 + Widget _buildHeader(BuildContext ctx, TextNovelReadLogic logic) { + final height = 44 + screen.paddingTop; + final fg = logic.isDark ? Colors.white : Colors.black; + return AnimatedPositioned( + duration: Duration(milliseconds: logic.durationTime), + top: logic.showMenu ? 0 : -height, + left: 0, + right: 0, + child: Container( + height: height, + color: logic.isDark ? Colors.black : Colors.white, + padding: + EdgeInsets.only(top: screen.paddingTop + 5, left: 16, right: 16), + alignment: Alignment.topCenter, //按钮贴状态栏下沿,余量留在底部 + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + InkWell( + enableFeedback: false, + onTap: Get.back, + child: Icon(Icons.arrow_back_ios, size: 24, color: fg), + ), + Expanded( + child: Text( + logic.detailInfo?.name ?? '', + style: TextStyle( + fontSize: 18, color: fg, fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // 日/夜间模式 + InkWell( + enableFeedback: false, + onTap: logic.changeDarkStyle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 7), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, anim) => + ScaleTransition(scale: anim, child: child), + child: Image.asset( + logic.isDark + ? 'text_sun.png'.acgImgPath + : 'text_moon.png'.acgImgPath, + // key 按状态区分,AnimatedSwitcher 才会把它当成新 child 触发动画 + key: ValueKey(logic.isDark), + width: 24, + ), + ), + ), + ), + // 目录 + InkWell( + enableFeedback: false, + onTap: () => Scaffold.of(ctx).openEndDrawer(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 7), + child: Image.asset('text_play_menu.png'.acgImgPath, + width: 24, color: fg), + ), + ), + ], + ), + ), + ); + } + + // 底部翻章栏,收起时整条移出屏幕下方 + Widget _buildFooter(TextNovelReadLogic logic) { + final style = TextStyle( + fontSize: 14, color: logic.isDark ? Colors.white : Colors.black); + return AnimatedPositioned( + duration: Duration(milliseconds: logic.durationTime), + bottom: logic.showMenu ? 0 : -60, + left: 0, + right: 0, + child: Container( + height: 60, + color: logic.isDark ? Colors.black : Colors.white, + padding: const EdgeInsets.only(top: 26, left: 16, right: 16), + alignment: Alignment.topCenter, //按钮贴上沿,余量留给底部安全区 + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + InkWell( + enableFeedback: false, + onTap: () => logic.loadOtherEpisode(false, showLoading: true), + child: Row( + children: [ + Image.asset('text_last.png'.acgImgPath, + color: AppColors.actionRed, width: 20, height: 20), + 12.sizeBoxW, + Text('上一章', style: style), + ], + ), + ), + InkWell( + enableFeedback: false, + onTap: () => logic.loadOtherEpisode(true, showLoading: true), + child: Row( + children: [ + Text('下一章', style: style), + 12.sizeBoxW, + Image.asset('text_next.png'.acgImgPath, + color: AppColors.actionRed, width: 20, height: 20), + ], + ), + ), + ], + ), + ), + ); + } + + // 右侧目录抽屉 + Widget _buildEndDrawer(TextNovelReadLogic logic) { + final fg = logic.isDark ? Colors.white : Colors.black; + return Container( + color: logic.isDark ? Colors.black : Colors.white, + width: 210, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 80.sizeBoxH, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + logic.mediaInfo?.title ?? '', + style: TextStyle( + fontSize: 16, + color: fg.withValues(alpha: .9), + fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + 18.sizeBoxH, + // 总集数 + 正倒序切换 + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: AppColors.actionRed, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '共${logic.mediaInfo?.totalEpisode ?? 0}话 ${logic.mediaInfo?.updateDesc ?? ''}', + style: const TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500), + ), + InkWell( + enableFeedback: false, + onTap: logic.onChangeSortAction, + child: GetBuilder( + id: 'sort', + builder: (_) => Row( + children: [ + AnimatedRotation( + turns: logic.isAscend ? 0 : 0.5, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + child: Image.asset('text_play_sort.png'.acgImgPath, + width: 20), + ), + 4.sizeBoxW, + Text( + logic.isAscend ? '正序' : '倒序', + style: const TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ], + ), + ), + Expanded( + child: GetBuilder( + id: 'list', + builder: (_) => ListView.builder( + key: + ValueKey(logic.isAscend), // 切换正/倒序时 key 变化→列表重建→item 重新错峰入场 + itemCount: logic.allEpisodes.length, + padding: const EdgeInsets.only(top: 6), + itemBuilder: (_, index) { + final info = logic.allEpisodes[index]; + final select = logic.detailInfo?.id == info.id; + return StaggerInItem( + index: index, + child: InkWell( + enableFeedback: + false, //换掉裸 GestureDetector:整行可点,不再只有文字那一小块 + onTap: () { + Get.back(); + logic.loadDataWithIndex(info.id ?? ''); + }, + child: Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.centerLeft, + child: Text( + //与详情/弹窗子集 title 格式一致:第X章 + '第${info.episodeNumber ?? 1}${logic.mediaInfo?.unit ?? ''}', + style: TextStyle( + fontSize: 16, + color: select + ? AppColors.actionRed + : fg.withValues(alpha: .55), + fontWeight: + select ? FontWeight.w500 : FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/cartoon/voice_novel_read_logic.dart b/lib/hj_page/cartoon/voice_novel_read_logic.dart new file mode 100644 index 0000000..888ca24 --- /dev/null +++ b/lib/hj_page/cartoon/voice_novel_read_logic.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/event_bus/event_bus_util.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; + +import 'widget/acg_source_manager.dart'; + +// 有声小说 logic +class VoiceNovelReadLogic extends GetxController { + final String id; + final String? title; + final int index; + final ACGSourceManager sourceManager; + bool isFirstLoad = true; + final AudioPlayerManager audioManager = AudioPlayerManager(); + StreamSubscription? _pauseVideoSub; + + VoiceNovelReadLogic({ + required this.sourceManager, + required this.id, + required this.index, + this.title, + }); + + @override + void onInit() { + super.onInit(); + audioManager.initPlayer(); + audioManager.sourceManger = sourceManager; + sourceManager.index = index; + // 耳机/蓝牙断开、来电中断时暂停,防止外放泄露(有声小说为纯音频,外放泄露更直接) + _pauseVideoSub = eventBus.on((_) => audioManager.pause()); + } + + @override + void onReady() { + super.onReady(); + isFirstLoad = false; + update(); + } + + @override + void onClose() { + _pauseVideoSub?.cancel(); + audioManager.dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/cartoon/voice_novel_read_page.dart b/lib/hj_page/cartoon/voice_novel_read_page.dart new file mode 100644 index 0000000..08ddb68 --- /dev/null +++ b/lib/hj_page/cartoon/voice_novel_read_page.dart @@ -0,0 +1,111 @@ +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/hj_utils/widget_util.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:provider/provider.dart'; + +import 'audio_player_content.dart'; +import 'cartoon_recommend_page.dart'; +import 'voice_novel_read_logic.dart'; +import 'widget/acg_source_manager.dart'; + +// 有声小说页面 +class VoiceNovelReadPage extends StatefulWidget { + final ACGSourceManager manager; + final String id; + final int index; + final String? title; + + const VoiceNovelReadPage({ + super.key, + required this.manager, + required this.id, + required this.index, + this.title, + }); + + @override + State createState() => _VoiceNovelReadPageState(); +} + +// 可重复 push(preventDuplicates: false)+ 间接环 NovelDetail→VoiceNovelRead 可能多实例并存, +// 用 UniqueTagMixin 给每个实例分独立 tag 隔离 +class _VoiceNovelReadPageState extends State + with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: VoiceNovelReadLogic( + sourceManager: widget.manager, + id: widget.id, + index: widget.index, + title: widget.title, + ), + builder: (logic) => ChangeNotifierProvider.value( + value: logic.sourceManager, + child: Scaffold( + appBar: AppBar( + leading: InkWell( + enableFeedback: false, + onTap: () => Get.back(), + child: const Icon(Icons.arrow_back_ios, + size: 24, color: Colors.white), + ), + centerTitle: false, + titleSpacing: -10, + title: Text( + logic.title ?? '', + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 18.sp, + fontWeight: FontWeight.w600, + ), + ), + ), + body: _buildBody(logic), + ), + ), + ); + } + + Widget _buildBody(VoiceNovelReadLogic logic) { + if (logic.isFirstLoad) return LoadingCenterWidget(); + if (logic.sourceManager.allEpisodes.isEmpty) return CErrorWidget(); + return ExtendedNestedScrollView( + headerSliverBuilder: (_, __) => [ + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: AudioPlayerContent( + logic.sourceManager, + index: logic.index, + manager: logic.audioManager, + ), + ), + ), + 0.5.sliverLine, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 18, 16, 12), + child: Text( + '相关推荐', + style: textStyle( + 18, Colors.white.withValues(alpha: .9), FontWeight.w600), + ), + ), + ), + ], + body: CartoonRecommendPage( + mediaId: + logic.sourceManager.mediaInfo?.tagDetails?.firstOrNull?.id ?? '', + padding: const EdgeInsets.only(top: 10), + tapAction: () => logic.audioManager.pause(), + ), + ); + } +} diff --git a/lib/hj_page/cartoon/widget/acg_expand_text.dart b/lib/hj_page/cartoon/widget/acg_expand_text.dart new file mode 100644 index 0000000..8af823e --- /dev/null +++ b/lib/hj_page/cartoon/widget/acg_expand_text.dart @@ -0,0 +1,109 @@ +//查看更多 +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../hj_utils/widget_util.dart'; + +class AcgShowMoreTextWidget extends StatefulWidget { + final String? summary; + final int? maxLine; + final double? maxWidth; + final Color txColor; + + const AcgShowMoreTextWidget({ + super.key, + this.summary, + this.maxLine = 3, + this.maxWidth, + this.txColor = const Color(0xff989898), + }); + + @override + State createState() => _AcgShowMoreTextWidgetState(); +} + +class _AcgShowMoreTextWidgetState extends State { + double turns = 1.0; //翻转动画累加值,整数=折叠态,每点击 +0.5 转半圈 + + bool get _isFold => turns % 1 == 0; + + @override + Widget build(BuildContext context) { + //用 LayoutBuilder 拿真实约束宽度测量,避免传入的 maxWidth 与实际渲染宽度不符导致"该展开却没按钮" + return LayoutBuilder(builder: (context, cons) { + final maxWidth = cons.maxWidth.isFinite + ? cons.maxWidth + : (widget.maxWidth ?? Get.width); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + //宽度拉满 + 居左:短文本也占满整行左对齐,避免父级把窄 Column 居中 + SizedBox( + width: double.infinity, + child: Text( + widget.summary?.isNotEmpty == true + ? widget.summary ?? '' + : '暂无简介', + textAlign: TextAlign.left, + style: textStyle(12, widget.txColor, FontWeight.w400), + maxLines: _isFold ? widget.maxLine : null, + overflow: _isFold ? TextOverflow.ellipsis : null, + ), + ), + if (_showMoreText(maxWidth)) + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _toggleExpand, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Text( + _isFold ? '更多简介' : '收起简介', + style: textStyle( + 12, const Color(0xffEFEFEF), FontWeight.w400), + ), + 2.sizeBoxW, + AnimatedRotation( + turns: turns, + duration: const Duration(milliseconds: 300), + child: Image.asset( + 'acg_narrow.png'.acgImgPath, + width: 18, + color: const Color(0xffDCDCDC), + ), + ), + ], + ), + ), + ), + ], + ); + }); + } + + //展开/收起 + void _toggleExpand() => setState(() => turns += 0.5); + + //文本超过 maxLine 才显示"更多简介"按钮 + bool _showMoreText(double maxWidth) { + return _textExceedMaxLines( + widget.summary ?? '.', + textStyle(12, widget.txColor, FontWeight.w400), + widget.maxLine ?? 3, + maxWidth, + ); + } + + bool _textExceedMaxLines( + String text, TextStyle style, int maxLine, double maxWidth) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + maxLines: maxLine, + textDirection: TextDirection.ltr, + )..layout(maxWidth: maxWidth); + return painter.didExceedMaxLines; + } +} diff --git a/lib/hj_page/cartoon/widget/acg_source_manager.dart b/lib/hj_page/cartoon/widget/acg_source_manager.dart new file mode 100644 index 0000000..7232c75 --- /dev/null +++ b/lib/hj_page/cartoon/widget/acg_source_manager.dart @@ -0,0 +1,417 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/cartoon_media_info.dart'; +import 'package:hgdj/hj_model/media_content.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/tools_base/event_bus/event_bus_util.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; +import 'package:hgdj/tools_base/loading/loading_helper.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../../../alert/video/comic_buy_alert.dart'; +import '../../../alert/video/vip_acg_dialog.dart'; +import '../../../hj_model/acg/comic_chapters_model.dart'; +import '../../../hj_utils/api_service/acg_service.dart'; +import '../../../tools_base/global_store/store.dart'; + +/// 小说/漫画/有声资源管理器 +class ACGSourceManager extends ChangeNotifier { + CartoonMediaInfo? mediaInfo; + List allEpisodes = []; + final mediaContentCache = {}; // 章节详情缓存 + + static const _maxPage = 50; // 章节分页拉取上限(50 页 × 60 条 = 3000 章) + static const _pageSize = 60; // 每页集数:一页尽量拉多点,少几轮串行翻页 + + String id; + int page = 1; + int index = 0; // 当前选中下标,从 0 开始 + + ACGSourceManager(this.id); + + /// 更新收藏状态 + void updateCollectState(bool collected) { + mediaInfo?.mediaStatus?.hasCollected = collected; + notifyListeners(); + } + + /// 点赞/取消点赞 + Future onLikeAction() async { + LoadingHelper.showLoading(); + final liked = mediaInfo?.mediaStatus?.hasLiked ?? false; + final mid = mediaInfo?.id ?? ''; + final type = mediaInfo?.mediaType ?? ''; + final suc = liked + ? await CommonService.cancelLike(mid, type) + : await CommonService.sendLike(mid, type); + LoadingHelper.dismissLoading(); + if (suc) { + mediaInfo?.mediaStatus?.hasLiked = !liked; + // 取消 like 数 -1;点 like 数 +1(原代码两个分支都 -- 是 bug) + mediaInfo?.countLike = (mediaInfo?.countLike ?? 0) + (liked ? -1 : 1); + } + notifyListeners(); + } + + /// 更新章节购买权限:buyAll=true 整本解锁,否则只解锁 subId 对应章节 + void updatePermission({bool? buyAll, String? subId}) { + if (buyAll == true) { + mediaInfo?.mediaStatus?.hasPaid = true; + for (final e in allEpisodes) { + e.hasBuy = true; + } + } else { + for (final e in allEpisodes) { + if (e.id == subId) e.hasBuy = true; + } + } + notifyListeners(); + } + + /// 阅读按钮文案:第一章=开始阅读;其它=续看 NNN集 + String actionTitle() { + final showCount = index + 1; + if (showCount == 1) return "开始阅读"; + final showText = showCount.toString().padLeft(3, '0'); + return "续看$showText${mediaInfo?.unit ?? ''}"; + } + + /// 切换当前播放章节 + void changeEpisodesIndex(int current, {bool jump = true}) { + index = current; + notifyListeners(); + if (jump) eventBus.emit(ACGMenuChanged(index: index)); + } + + /// 拉所有章节(分页,每页 [_pageSize] 条,最多 [_maxPage] 页) + /// 注:暂不支持刷新重拉——真要加,必须连 mediaContentCache 一起清(它以下标为 key,列表变了会串章) + Future fetchAllEpisodes() async { + // 改为 while 循环:原代码递归没 await;再加页数上限,防后端 hasNext 恒 true 时无限翻页 + while (page <= _maxPage) { + final result = await ACGService.getChapterList(id, page, _pageSize); + if (result == null) break; + final list = result.list ?? []; + // 每页拉到就按集号标免费集(与播放放行/角标同一口径),避免全部拉完才标导致角标迟到 + for (final e in list) { + e.inFreeEpisode = + mediaInfo?.isFreeEpisodeNumber(e.episodeNumber) ?? false; + } + allEpisodes.addAll(list); + notifyListeners(); + if (!(result.hasNext ?? false)) break; + page += 1; + } + } + + /// 检测章节权限。permission:0=VIP可看 1=金币购买 2=免费 + /// 拿到权限后会同时切换 current index 并返回 episode;否则返回 null + Future getEpisodeHasPermisson(int index_) async { + if (allEpisodes.isEmpty) return null; + final epm = allEpisodes[index_]; + final permission = mediaInfo?.permission ?? 0; + final unLock = mediaInfo?.mediaStatus?.hasPaid ?? false; + final isPremiumVIP = globalStore.isSuperUp; + final inFreeEpisode = epm.inFreeEpisode; // 本地字段:前 N 集免费,直接放行 + + bool hasPermission = false; + if (unLock || isPremiumVIP || inFreeEpisode) { + hasPermission = true; + } else { + switch (permission) { + case 2: // 免费 + hasPermission = true; + break; + case 0: // 需要 VIP + hasPermission = globalStore.isVIP; + if (!hasPermission) { + await Get.dialog(VipACGDialog(), useSafeArea: false); + } + break; + case 1: // 金币购买 + hasPermission = mediaInfo?.mediaStatus?.hasPaid ?? false; + if (!hasPermission) { + final result = await ComicBuyAlert.show(mediaInfo: mediaInfo); + // 1 购买单集 2 购买全集 + if (result == 1 || result == 2) { + hasPermission = true; + if (result == 2) updatePermission(buyAll: true); + epm.hasBuy = true; + } + } + break; + } + } + if (hasPermission) changeEpisodesIndex(index_); + return hasPermission ? epm : null; + } + + /// 拉漫画章节详情(带缓存 + 权限校验) + Future getComicsMediaContent( + {int index = 0, bool showLoading = false}) async { + final cached = mediaContentCache[index]; + if (cached != null) { + changeEpisodesIndex(index); // 命中缓存也要同步当前章,否则退回详情页「续看」还停在旧章 + return cached; + } + final episode = await getEpisodeHasPermisson(index); + if (episode == null) return null; + // loading 开在权限校验之后:VIP/购买弹窗不能被 loading 遮罩挡住 + if (showLoading) LoadingHelper.showLoading(); + final result = await ACGService.getMediaDetail(id: episode.id ?? ''); + if (showLoading) LoadingHelper.dismissLoading(); + if (result != null) mediaContentCache[index] = result; + return result; + } + + /// 上一集 + Future backToForward() async { + if (index == 0) { + showToast('当前是第一章~~~'); + return null; + } + return getEpisodeHasPermisson(index - 1); + } + + /// 下一集 + Future jumpToNext() async { + if (index == allEpisodes.length - 1) { + showToast('当前是最后一章了~~~'); + return null; + } + return getEpisodeHasPermisson(index + 1); + } +} + +enum AudioPlayMod { + loop, + loopList, +} + +class AudioPlayerValue { + Duration? position; + Duration? duration; + bool? isPlaying; + bool isLooping; + double volume; + bool isCompleted; + bool isInitialized; + int? onPlayIndex; + + AudioPlayerValue({ + this.position, + this.duration, + this.isPlaying, + this.volume = 1, + this.isLooping = true, + this.isCompleted = false, + this.isInitialized = false, + this.onPlayIndex, + }); + + static AudioPlayerValue copyWith(AudioPlayerValue value) { + return AudioPlayerValue( + duration: value.duration, + position: value.position, + isPlaying: value.isPlaying, + isCompleted: value.isCompleted, + volume: value.volume, + isLooping: value.isLooping, + isInitialized: value.isInitialized, + onPlayIndex: value.onPlayIndex, + ); + } +} + +/// 有声小说播放器封装:包装 audioplayers + 列表切换 +class AudioPlayerManager { + AudioPlayerManager() { + audioValue = ValueNotifier(value); + } + + AudioPlayer? _audioPlayer; + + AudioPlayMod playMod = AudioPlayMod.loopList; + ACGSourceManager? sourceManger; + + StreamSubscription? _audioPositionSub; + StreamSubscription? _audioDurationSub; + StreamSubscription? _audioStateSub; + StreamSubscription? _audioCompleSub; + StreamSubscription? _seekSub; + + final AudioPlayerValue value = AudioPlayerValue(); + late ValueNotifier audioValue; + + double playSpeed = 1; + + void initPlayer() { + if (_audioPlayer != null) + return; // 防重入:已初始化则跳过,避免 5 个 stream 监听重复注册、旧 sub 泄漏 + _audioPlayer = AudioPlayer(); + _audioPlayer?.setReleaseMode( + playMod == AudioPlayMod.loop ? ReleaseMode.loop : ReleaseMode.stop); + + _audioDurationSub = _audioPlayer?.onDurationChanged.listen((event) { + audioValue.value = AudioPlayerValue.copyWith(value..duration = event); + }); + + _audioPositionSub = _audioPlayer?.onPositionChanged.listen((event) { + audioValue.value = AudioPlayerValue.copyWith(value + ..position = event + ..isPlaying = true + ..onPlayIndex = sourceManger?.index ?? 0); + }); + + _audioStateSub = _audioPlayer?.onPlayerStateChanged.listen((event) { + switch (event) { + case PlayerState.completed: + audioValue.value = AudioPlayerValue.copyWith(value + ..isCompleted = true + ..position = null); + break; + case PlayerState.playing: + audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true); + break; + case PlayerState.paused: + audioValue.value = + AudioPlayerValue.copyWith(value..isPlaying = false); + break; + default: + break; + } + }); + + _audioCompleSub = _audioPlayer?.onPlayerComplete.listen((event) { + if (playMod == AudioPlayMod.loopList && (value.isPlaying ?? false)) { + audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false); + playNext(); + } else { + audioValue.value = AudioPlayerValue.copyWith(value + ..isCompleted = true + ..isPlaying = false); + } + }); + + _seekSub = _audioPlayer?.onSeekComplete.listen((event) { + audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true); + }); + + audioValue.value = + AudioPlayerValue.copyWith(value..isInitialized = _audioPlayer != null); + } + + void setPlaySpeed(double speed) { + playSpeed = speed; + _audioPlayer?.setPlaybackRate(speed); + } + + /// 开始播放:url 为空则重置状态 + void play(String url) { + if (_audioPlayer == null) initPlayer(); + audioValue.value = AudioPlayerValue.copyWith( + value + ..isPlaying = false + ..duration = null + ..position = Duration.zero, + ); + if (url.isEmpty) return; + + // play 是 Future 但不 await(保留 fire-and-forget 行为); + // 必须挂 catchError,否则 audioplayers 30s 没收到 prepared 时抛 TimeoutException + // 会变成未处理异常炸 console,UI 也卡在 "加载中" + _audioPlayer?.play(UrlSource(url)).catchError((Object e) { + debugPrint('[AudioPlayer] play failed: $e'); + audioValue.value = AudioPlayerValue.copyWith(value + ..isPlaying = false + ..isCompleted = true + ..duration = null); + showToast('音频加载失败'); + }); + } + + /// 上一集 + Future playForward() async { + final epm = await sourceManger?.backToForward(); + if (epm != null) { + play(epm.getRealAudioUrl); + } else { + pause(); + } + } + + /// 下一集 + Future playNext() async { + final epm = await sourceManger?.jumpToNext(); + if (epm != null) { + play(epm.getRealAudioUrl); + } else { + pause(); + } + } + + Future pause() async { + await _audioPlayer?.pause(); + audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false); + } + + Future resume() async { + await _audioPlayer?.resume(); + audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true); + } + + void setPlayMod(AudioPlayMod mod) { + _audioPlayer?.setReleaseMode( + mod == AudioPlayMod.loop ? ReleaseMode.loop : ReleaseMode.stop); + playMod = mod; + audioValue.value = + AudioPlayerValue.copyWith(value..isLooping = mod == AudioPlayMod.loop); + } + + Future playWithIndex(int index) async { + if (index == sourceManger?.index) { + if (!(value.isPlaying ?? false)) resume(); + return; + } + final epm = await sourceManger?.getEpisodeHasPermisson(index); + if (epm != null) { + play(epm.getRealAudioUrl); + } else { + pause(); + } + } + + /// 跳转到指定秒数 + void seek(int seconds) { + _audioPlayer?.seek(Duration(seconds: seconds)); + if (value.isPlaying ?? false) { + audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false); + } + } + + /// 前进/后退 10 秒(dir: -1 后退,1 前进) + void seekTenMinsWithDirection(int dir) { + final cur = audioValue.value.position?.inSeconds ?? 0; + final total = audioValue.value.duration?.inSeconds ?? 0; + int target; + if (dir == -1) { + target = cur < 10 ? 0 : cur - 10; + } else { + target = (total - cur) < 10 ? total : cur + 10; + } + seek(target); + } + + void dispose() { + _audioPlayer?.pause(); + _audioPlayer?.release(); + _audioPlayer = null; + _audioPositionSub?.cancel(); + _audioDurationSub?.cancel(); + _audioStateSub?.cancel(); + _audioCompleSub?.cancel(); + _seekSub?.cancel(); + } +} diff --git a/lib/hj_page/cartoon/widget/cartoon_subset_alert.dart b/lib/hj_page/cartoon/widget/cartoon_subset_alert.dart new file mode 100644 index 0000000..0615587 --- /dev/null +++ b/lib/hj_page/cartoon/widget/cartoon_subset_alert.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/acg/comic_chapters_model.dart'; +import 'package:hgdj/hj_model/cartoon_media_info.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; +import 'package:hgdj/tools_base/widget/stagger_in_item.dart'; + +import 'acg_source_manager.dart'; +import 'free_badge.dart'; + +//漫画子集底部弹窗 +class CartoonSubSetAlert extends StatefulWidget { + final ACGSourceManager? sourceManager; + + const CartoonSubSetAlert({super.key, this.sourceManager}); + + @override + State createState() => _CartoonSubSetAlertState(); +} + +class _CartoonSubSetAlertState extends State { + CartoonMediaInfo? get mediaInfo => widget.sourceManager?.mediaInfo; + List allEpisodes = []; + bool isAscending = true; // true-升序 false-降序 + + @override + void initState() { + super.initState(); + allEpisodes.addAll(widget.sourceManager?.allEpisodes ?? []); + } + + @override + Widget build(BuildContext context) { + return Container( + width: Get.width, + height: 425, + decoration: const BoxDecoration( + color: AppColors.primaryColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Column( + children: [ + 18.sizeBoxH, + const SheetHandleBar(color: Color(0xff989898)), + 18.sizeBoxH, + _buildHeader(), + Expanded(child: _buildEpisodeList()), + ], + ), + ); + } + + Widget _buildHeader() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 16.sizeBoxW, + const Text( + '漫画列表', + style: TextStyle( + color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), + ), + 10.sizeBoxW, + const Spacer(), + InkWell( + enableFeedback: false, + onTap: changeSortType, + child: Row( + children: [ + AnimatedRotation( + turns: isAscending ? 0 : 0.5, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + child: Image.asset('text_play_sort.png'.acgImgPath, width: 20), + ), + 4.sizeBoxW, + Text( + isAscending ? '正序' : '倒序', + style: TextStyle( + color: Colors.white.withValues(alpha: .5), + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + 22.sizeBoxW, + ], + ); + } + + Widget _buildEpisodeList() { + return ListView.builder( + key: ValueKey(isAscending), // 切换正/倒序时 key 变化→列表重建→item 重新错峰入场 + padding: const EdgeInsets.symmetric(vertical: 9), + itemCount: allEpisodes.length, + itemBuilder: (context, index) { + final info = allEpisodes[index]; + final realIndex = (info.episodeNumber ?? 1) - 1; + final sel = realIndex == widget.sourceManager?.index; + return StaggerInItem( + index: index, + child: InkWell( + enableFeedback: false, + onTap: () => Get.back(result: realIndex), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), + child: Row( + children: [ + Text( + //与详情/阅读抽屉子集 title 一致:第X话(CartoonDetailPage 只处理 image,恒为漫画) + '第${info.episodeNumber ?? ''}${mediaInfo?.unit ?? ''}', + style: TextStyle( + color: sel + ? Colors.white.withValues(alpha: .9) + : Colors.white.withValues(alpha: .45), + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + //无任何权限时,前 N 集免费展示「免费」角标 + if (mediaInfo?.hasPermission == false && + info.inFreeEpisode) ...[ + 8.sizeBoxW, + const FreeBadge(), + ], + ], + ), + ), + ), + ); + }, + ); + } + + void changeSortType() { + isAscending = !isAscending; + final list = widget.sourceManager?.allEpisodes ?? []; + allEpisodes = isAscending ? List.of(list) : list.reversed.toList(); + setState(() {}); + } +} diff --git a/lib/hj_page/cartoon/widget/free_badge.dart b/lib/hj_page/cartoon/widget/free_badge.dart new file mode 100644 index 0000000..883eec7 --- /dev/null +++ b/lib/hj_page/cartoon/widget/free_badge.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +/// ACG「免费」角标:整本无任何权限(hasPermission == false)且该集落在前 N 集免费区间时展示。 +/// 默认行内胶囊(详情页/子集抽屉/有声列表);[isCorner] 为卡片左上角贴边样式(选集面板小方块)。 +class FreeBadge extends StatelessWidget { + final bool isCorner; + + const FreeBadge({super.key, this.isCorner = false}); + + static const _bgColor = Color(0xff64CBC2); + + @override + Widget build(BuildContext context) { + if (isCorner) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: const BoxDecoration( + color: _bgColor, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(7), + bottomRight: Radius.circular(7), + ), + ), + child: const Text( + '免费', + style: TextStyle(color: Colors.white, fontSize: 9, height: 1.2, fontWeight: FontWeight.w400), + ), + ); + } + return Container( + height: 20, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: _bgColor, + borderRadius: BorderRadius.circular(3), + ), + child: const Text( + '免费', + style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w400), + ), + ); + } +} diff --git a/lib/hj_page/comment/cell/comment_item.dart b/lib/hj_page/comment/cell/comment_item.dart new file mode 100644 index 0000000..db3164e --- /dev/null +++ b/lib/hj_page/comment/cell/comment_item.dart @@ -0,0 +1,318 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/banner/comment_top_banner_model.dart'; +import 'package:hgdj/hj_model/comment/comment_model.dart'; +import 'package:hgdj/hj_model/comment/reply_model.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/comment/cell/reply_comment_item.dart'; +import 'package:hgdj/hj_utils/api_service/comment_service.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/widget/image_browser_page.dart'; +import 'package:like_button/like_button.dart'; + +import '../../../tools_base/widget/net_image_widget.dart'; +import '../../home/widget/user_avatar.dart'; +import '../../home/widget/user_name_view.dart'; +import '../widget/comment_rich_text.dart'; +import '../widget/comment_top_banner.dart'; + +/// 一条评论:头像 + 昵称 + 正文(可带图) + 时间/点赞/回复 + 折叠的回复列表 +class CommentItem extends StatefulWidget { + final CommentModel comment; + final String objId; + final List? topBanners; // 置顶评论下方配置 Banner + final Function(CommentModel, {ReplyModel? reply})? replyHandler; + + const CommentItem( + this.comment, { + super.key, + required this.objId, + this.replyHandler, + this.topBanners, + }); + + @override + State createState() => _CommentItemState(); +} + +class _CommentItemState extends State { + CommentModel get comment => widget.comment; + + late final isLikedNof = ValueNotifier(comment.isLike ?? false); + bool _isLoadingMore = false; + bool _isExpand = false; + int _page = 1; + + @override + void dispose() { + isLikedNof.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () {}, + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UserAvatar( + size: 40, + model: Publisher() + ..uid = comment.userID + ..portrait = comment.userPortrait + ..superUser = (comment.superUser ?? false), + showVip: false, + ), //头像 + 12.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + UserNameView( + name: comment.userName, + isVip: (comment.vipLevel ?? 0) > 0, + nameColor: Color(0xff9A9A9A), + ), + if ((comment.vipLevel ?? 0) > 0) ...[ + 4.sizeBoxW, + Image.asset("vip_v.webp".commentPath, + width: 16), + ] + ], + ), + 8.sizeBoxH, + CommentRichText(model: comment), + if (comment.image?.isNotEmpty == true) + GestureDetector( + onTap: () { + ImageBrowserPage.open([comment.image ?? '']); + }, + child: Container( + margin: EdgeInsets.only(top: 6), + alignment: Alignment.centerLeft, + constraints: BoxConstraints(maxWidth: 150), + child: NetworkImageLoader( + imageUrl: comment.image ?? ""), + ), + ), + // 置顶评论配置 Banner(多图轮播,支持 GIF / 内外链) + if (widget.topBanners?.isNotEmpty == true) + CommentTopBanner(banners: widget.topBanners!), + 8.sizeBoxH, + Row( + children: [ + Text( + comment.createdAt?.utcToAgo() ?? "", + style: TextStyle( + color: Color(0x73FFFFFF), + fontSize: 12, + ), + ), + const Spacer(), + if (!comment.isOfficial && + !comment.isHideLikeOrComment) ...[ + ValueListenableBuilder( + valueListenable: isLikedNof, + builder: (context, value, child) => Row( + children: [ + LikeButton( + isLiked: value, + onTap: (_) => _toggleLike(), + size: 20, + likeBuilder: (isLiked) { + return Image.asset(isLiked + ? 'like_red.webp'.commentPath + : 'like_white.webp'.commentPath); + }, + ), + Text( + comment.likeCount?.countOr("0") ?? "0", + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 12, + ), + ), + ], + ), + ), + 12.sizeBoxW, + GestureDetector( + onTap: () => + widget.replyHandler?.call(comment), + child: Image.asset('msg_icon.png'.commentPath, + width: 20), + ), + ], + ], + ), + _replyList(), + // 展开更多评论 + if ((comment.replies?.length ?? 0) > 0) + _moreReplyBar(), + ], + ), + ), + ], + ), + 18.sizeBoxH, + const Divider(height: 1, color: Color(0x0fffffff)), + 18.sizeBoxH, + ], + ), + if ((comment.isGodComment ?? false)) + Positioned( + right: 8, + top: -10, + child: Transform.rotate( + angle: pi / 60, + child: Image.asset( + 'god_comment.webp'.commentPath, + width: 72, + height: 60, + ), + ), + ), + ], + )); + } + + /// 展开 / 收起那一行:短横线 + 文案 + 箭头 + Widget _replyBarRow(String text, {bool isUp = false}) { + final chevron = Image.asset("chevron_down.webp".commonImgPath, width: 16); + return Row( + children: [ + Container(width: 20, height: 1, color: const Color(0xff989898)), + 4.sizeBoxW, + Text(text, + style: const TextStyle( + fontSize: 12, + color: Color(0xff989898), + fontWeight: FontWeight.w500)), + 2.sizeBoxW, + isUp ? Transform.rotate(angle: pi, child: chevron) : chevron, + ], + ); + } + + Widget _moreReplyBar() { + final replyCount = comment.replies?.length ?? 0; + if (replyCount > 1 && _isExpand) { + //收起:padding 在点击区里面,跟改动前一致 + return GestureDetector( + onTap: () => setState(() => _isExpand = false), + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: _replyBarRow("收起${comment.commCount ?? 0}条回复", isUp: true), + ), + ); + } + final diff = (comment.commCount ?? 0) - (_isExpand ? replyCount : 1); + if (diff <= 0) return const SizedBox.shrink(); + if (_isLoadingMore) { + return const Padding( + padding: EdgeInsets.only(top: 12, left: 6), + child: CupertinoActivityIndicator(color: Colors.white, radius: 8), + ); + } + //展开:padding 在点击区外面别算进热区;外面这层 Row 也不能省—— + //它给 InkWell 的是无界宽约束,InkWell 才会只包住文字而不是撑满整行 + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + InkWell( + enableFeedback: false, + onTap: _expandReplies, + child: _replyBarRow(replyCount > 99 ? '展开更多回复' : '展开$diff条回复'), + ), + ], + ), + ); + } + + // 展开回复 + Future _expandReplies() async { + if (comment.replies?.isNotEmpty != true) { + comment.commCount = 0; + setState(() {}); + return; + } + if (_isLoadingMore) return; + _isLoadingMore = true; + setState(() {}); + _isExpand = true; + try { + if (comment.hasMoreReply ?? false) { + final curTime = DateTimeUtil.format2utc(DateTime.now()); + final fastId = comment.replies!.first.id ?? ''; + final result = await CommentService.getReplyList(widget.objId, + comment.id ?? '', curTime, comment.replyPage ?? _page, 5, fastId); + comment.hasMoreReply = result?.hasNext ?? false; + comment.replies?.addAll(result?.list ?? []); + if (result?.hasNext == true) { + _page += 1; + comment.replyPage = (comment.replyPage ?? 0) + 1; + } else { + comment.commCount = comment.replies?.length ?? 0; + } + } + } catch (e) { + debugLog(e); + } + _isLoadingMore = false; + if (mounted) setState(() {}); + } + + Future _toggleLike() async { + final isLike = comment.isLike ?? false; + bool result; + if (!isLike) { + result = await CommonService.sendLike(comment.id ?? '', 'COMMENT'); + if (result) comment.likeCount = (comment.likeCount ?? 0) + 1; + } else { + result = await CommonService.cancelLike(comment.id ?? '', 'COMMENT'); + if (result) comment.likeCount = (comment.likeCount ?? 1) - 1; + } + if (result) comment.isLike = !isLike; + isLikedNof.value = comment.isLike!; + return isLikedNof.value; + } + + //没展开时只露第一条 + Widget _replyList() { + final replies = comment.replies ?? []; + if (replies.isEmpty) return const SizedBox.shrink(); + final length = (!_isExpand && replies.length > 1) ? 1 : replies.length; + return Column( + //stretch 对齐原来 ListView 的行为:子项撑满宽度,不按内容自适应 + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: List.generate( + length, + (i) => ReplyCommentItem( + replies[i], + replyHandler: (reply) => + widget.replyHandler?.call(comment, reply: reply), + ), + ), + ); + } +} diff --git a/lib/hj_page/comment/cell/reply_comment_item.dart b/lib/hj_page/comment/cell/reply_comment_item.dart new file mode 100644 index 0000000..03b73f7 --- /dev/null +++ b/lib/hj_page/comment/cell/reply_comment_item.dart @@ -0,0 +1,175 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/image_browser_page.dart'; +import 'package:like_button/like_button.dart'; + +import '../../../hj_model/comment/reply_model.dart'; +import '../../../hj_utils/api_service/common_service.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import '../../user_center_page/user_center_page.dart'; + +/// 评论下的一条回复:小头像 + 「A ▸ B」 + 正文(可带图) + 时间/点赞/回复 +class ReplyCommentItem extends StatefulWidget { + final ReplyModel reply; + final Function(ReplyModel)? replyHandler; + + const ReplyCommentItem(this.reply, {super.key, this.replyHandler}); + + @override + State createState() => _ReplyCommentItemState(); +} + +class _ReplyCommentItemState extends State { + ReplyModel get reply => widget.reply; + late final isLikedNof = ValueNotifier(reply.isLike ?? false); + + @override + void dispose() { + isLikedNof.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 16.sizeBoxH, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () { + if (reply.userID != null) { + Get.to(() => UserCenterPage(uid: reply.userID ?? 0)); + } + }, + child: NetworkImageLoader( + imageUrl: reply.userPortrait ?? '', + width: 24, + height: 24, + borderRadius: 18, + ), + ), + 12.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + reply.userName ?? '', + style: TextStyle( + color: Color(0x73FFFFFF), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + 3.sizeBoxW, + if (reply.toUserName?.isNotEmpty == true) ...[ + Image.asset( + "arrow_grey.webp".commentPath, + width: 16, + height: 16, + ), + 3.sizeBoxW, + Text( + reply.toUserName ?? '', + style: + TextStyle(color: Color(0x73FFFFFF), fontSize: 14), + ), + ] + ], + ), // 还有个类似评分的东西 + 8.sizeBoxH, + Text( + reply.content ?? '', + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 14, + ), + ), + if (reply.image?.isNotEmpty == true) + GestureDetector( + onTap: () { + ImageBrowserPage.open([reply.image ?? '']); + }, + child: Container( + margin: EdgeInsets.only(top: 6), + alignment: Alignment.centerLeft, + constraints: BoxConstraints(maxWidth: 150), + child: NetworkImageLoader(imageUrl: reply.image ?? ""), + ), + ), + 8.sizeBoxH, + Row( + children: [ + Text( + reply.createdAt?.utcToAgo() ?? "", + style: const TextStyle( + color: Color(0x73FFFFFF), fontSize: 12), + ), + const Spacer(), + ValueListenableBuilder( + valueListenable: isLikedNof, + builder: (context, value, child) => Row( + children: [ + LikeButton( + isLiked: value, + onTap: (_) => _toggleLike(), + size: 20, + likeBuilder: (isLiked) { + return Image.asset(isLiked + ? 'like_red.webp'.commentPath + : 'like_white.webp'.commentPath); + }, + ), + Text( + reply.likeCount?.countOr("0") ?? "0", + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 12, + ), + ), + ], + ), + ), + 12.sizeBoxW, + GestureDetector( + onTap: () => widget.replyHandler?.call(reply), + child: + Image.asset('msg_icon.png'.commentPath, width: 20), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ); + } + + // 评论点赞 + Future _toggleLike() async { + final isLike = reply.isLike ?? false; + bool result; + if (!isLike) { + result = await CommonService.sendLike(reply.id ?? '', 'COMMENT'); + if (result) reply.likeCount = (reply.likeCount ?? 0) + 1; + } else { + result = await CommonService.cancelLike(reply.id ?? '', 'COMMENT'); + if (result) reply.likeCount = (reply.likeCount ?? 1) - 1; + } + if (result) reply.isLike = !isLike; + isLikedNof.value = reply.isLike!; + return isLikedNof.value; + } +} diff --git a/lib/hj_page/comment/comment_alert.dart b/lib/hj_page/comment/comment_alert.dart new file mode 100644 index 0000000..9e309f8 --- /dev/null +++ b/lib/hj_page/comment/comment_alert.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; + +import 'comment_views.dart'; + +Future showCommentDialog( + String objId, { + String objType = "video", + int? commentCount, +}) async { + return Get.bottomSheet( + Container( + alignment: Alignment.topLeft, + height: screen.screenHeight * 0.72, + decoration: BoxDecoration( + color: Color(0xff141414), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + alignment: Alignment.center, + padding: EdgeInsets.only(top: 12, bottom: 12), + child: const SheetHandleBar(color: Color(0x1AFFFFFF)), + ), + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 16), + child: Text( + "${commentCount ?? 0}条评论", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w400, + fontSize: 14), + ), + ), + Expanded( + child: CommentView(objId, objType: objType), + ), + ], + ), + ), + elevation: 0.72, + ); +} diff --git a/lib/hj_page/comment/comment_input_view.dart b/lib/hj_page/comment/comment_input_view.dart new file mode 100644 index 0000000..f728342 --- /dev/null +++ b/lib/hj_page/comment/comment_input_view.dart @@ -0,0 +1,178 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/group_text_filed.dart'; +import 'package:image_pickers/image_pickers.dart'; + +/// 评论输入弹层:点空白关闭,自动聚焦拉起键盘 +class CommentInputView extends StatefulWidget { + static const String routeName = 'comment_input'; + final String? hint; + final TextEditingController textCtr; + final String? imgPath; + final Function(String?)? onImgChange; + + const CommentInputView({ + super.key, + this.hint, + required this.textCtr, + this.imgPath, + this.onImgChange, + }); + + @override + State createState() => _CommentInputViewState(); +} + +class _CommentInputViewState extends State { + final focus = FocusNode(); + + TextEditingController get textCtr => widget.textCtr; + //弹层内自己维护一份,选/删图先本地刷新再回调出去 + String? _imgPath; + + @override + void initState() { + super.initState(); + _imgPath = widget.imgPath; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + focus.requestFocus(); + }); + } + + @override + void dispose() { + focus.dispose(); + super.dispose(); + } + + void _pickImage() async { + final listMedia = await ImagePickers.pickerPaths( + uiConfig: UIConfig(uiThemeColor: Colors.white), + galleryMode: GalleryMode.image, + selectCount: 1, + showCamera: false, + ); + if (listMedia.isNotEmpty) { + _imgPath = listMedia.first.path ?? ''; + widget.onImgChange?.call(_imgPath); + } + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + enableFeedback: false, + onTap: () => Get.back(), + child: Container( + alignment: Alignment.bottomCenter, + child: GestureDetector( + onTap: () {}, + child: Container( + color: Colors.black, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (_imgPath?.isNotEmpty == true) + Container( + margin: const EdgeInsets.only(bottom: 12), + width: 60, + height: 60, + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(_imgPath!), + width: 60, + height: 60, + fit: BoxFit.cover, + ), + Positioned( + top: 0, + right: 6, + child: GestureDetector( + onTap: () { + _imgPath = null; + widget.onImgChange?.call(null); + setState(() {}); + }, + child: Image.asset( + "close_grey.png".commonImgPath, + width: 12, + height: 12, + ), + ), + ), + ], + ), + ), + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: GroupTextFiled( + focusNode: focus, + controller: textCtr, + height: 32, + padding: EdgeInsets.symmetric(horizontal: 16), + textStyle: + TextStyle(color: Colors.white, fontSize: 14), + onSubmitted: (value) { + Get.back(result: true); + }, + placeholder: widget.hint ?? '请在此输入评论...', + placeholderTextStyle: TextStyle( + color: Color(0xff4D4D4D), fontSize: 14), + decoration: BoxDecoration( + color: Color(0xff1A1A1A), + borderRadius: BorderRadius.circular(40)), + ), + ), + ), + 12.sizeBoxW, + Padding( + padding: const EdgeInsets.only( + right: 12, top: 4, bottom: 4), + child: GestureDetector( + onTap: _pickImage, + child: Image.asset( + 'icon_pic.webp'.commentPath, + width: 30, + height: 30, + ), + ), + ), + GestureDetector( + onTap: () { + if (textCtr.text.isEmpty) { + showToast('请输入想要说的话哦~~'); + return; + } + Get.back(result: true); + }, + child: Image.asset( + 'ic_send.webp'.commentPath, + width: 30, + height: 30, + ), + ), + ], + ), + ], + ), + ), + )), + ), + ); + } +} diff --git a/lib/hj_page/comment/comment_mixin.dart b/lib/hj_page/comment/comment_mixin.dart new file mode 100644 index 0000000..e6cc068 --- /dev/null +++ b/lib/hj_page/comment/comment_mixin.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/comment/comment_model.dart'; +import 'package:hgdj/hj_model/comment/reply_model.dart'; +import 'package:hgdj/hj_page/comment/comment_input_view.dart'; +import 'package:hgdj/hj_utils/api_service/comment_service.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../hj_model/banner/comment_top_banner_model.dart'; +import '../../hj_model/comment/comment_list_res.dart'; +import '../../tools_base/file_upload/file_upload_tool.dart'; +import '../../tools_base/file_upload/upload_result_model.dart'; + +mixin CommentMixin on GetxController { + final comments = []; + final commentController = TextEditingController(); + Function(int)? totalCallback; + CommentModel? replyComment; + ReplyModel? replyCommentModel; + int page = 1; + String? hintText; + Rx selectedImagePath = Rx(null); + RefreshController? refreshCtr; + final quickSearchList = []; + final commentTopBanners = []; + String? id; + String? objType; + RxBool isSendingMsg = false.obs; + + @override + void onClose() { + commentController.dispose(); + super.onClose(); + } + + Future sendComment() async { + String content = commentController.text; + if (content.isEmpty) { + showToast('请输入评论内容哦~'); + return; + } + isSendingMsg.value = true; + String? sendImagePath; + if (selectedImagePath.value?.isNotEmpty == true) { + try { + ImageUploadResultModel? imgResult = + await FileUploadTool().uploadImage(selectedImagePath.value!); + sendImagePath = imgResult?.coverImg ?? ""; + if (sendImagePath.isEmpty) { + isSendingMsg.value = false; + showToast("图片上传失败!"); + return; + } + } catch (e) { + debugLog(e); + isSendingMsg.value = false; + showToast("图片上传失败!"); + return; + } + } + + try { + if (replyComment != null) { + // 回复评论 + final res = await CommentService.sendReply( + id, + 2, + content, + rid: replyCommentModel?.id, + cid: replyComment?.id, + toUserID: replyCommentModel?.toUserID, + objType: objType, + image: sendImagePath, + ); + if (res != null) { + replyComment?.replies ??= []; + replyComment?.replies?.insert(0, res); + replyComment?.commCount = replyComment?.replies?.length ?? 0; + replyComment = null; + replyCommentModel = null; + hintText = null; + showToast('已提交,审核通过后将展示'); + } + update(); + } else { + // 评论 + final res = await CommentService.sendComment( + id, + 1, + content, + objType ?? '', + image: sendImagePath, + ); + if (res == null) { + hintText = null; + } else { + showToast('已提交,审核通过后将展示'); + hintText = null; + //插在第一条普通评论前面,置顶那几条不动 + final insertIndex = + comments.indexWhere((e) => !e.isHideLikeOrComment); + comments.insert( + insertIndex == -1 ? comments.length : insertIndex, res); + } + update(); + } + } catch (e) { + debugLog(e); + } + selectedImagePath.value = null; + commentController.text = ""; + isSendingMsg.value = false; + } + + // isSend: true 点击了发送按钮, 如果没有发送内容弹键盘, 有内容直接发送 + readyReplyComment(CommentModel? comment, + {ReplyModel? replyModel, bool? isSend}) async { + replyComment = comment; + replyCommentModel = replyModel; + if (replyModel != null) { + hintText = '回复:${replyModel.userName ?? ''}'; + } + if (comment != null && hintText == null) { + hintText = '回复:${comment.userName ?? ''}'; + } + if (isSend == true && commentController.text.isNotEmpty) { + update(); + sendComment(); + } else { + final result = await Get.bottomSheet( + CommentInputView( + hint: hintText, + textCtr: commentController, + imgPath: selectedImagePath.value, + onImgChange: (value) => selectedImagePath.value = value, + ), + backgroundColor: Colors.transparent, + barrierColor: Colors.transparent, + enableDrag: false, + settings: RouteSettings(name: CommentInputView.routeName), + useRootNavigator: true, + ); + if (result == true) { + update(); + sendComment(); + } + } + } + + // 获取评论 + Future fetchComments(String id, {bool isRefresh = false}) async { + try { + if (isRefresh) { + page = 1; + } + final currentT = DateTimeUtil.format2utc(DateTime.now()) ?? ""; + final result = + await CommentService.getCommentList(id, currentT, page, 20); + totalCallback?.call(result?.total ?? 0); + refreshCtr?.refreshCompleted(); + refreshCtr?.loadComplete(); + if (!(result?.hasNext ?? false)) { + refreshCtr?.loadNoData(); + } + if (page == 1) { + comments.clear(); + quickSearchList.clear(); + // 首屏并行拉置顶 Banner;失败不影响评论列表 + _fetchCommentTopBanners(); + } + comments.addAll(result?.list ?? []); + if (page == 1) { + quickSearchList.addAll(result?.quickSearchList ?? []); + } + page += 1; + for (final e in comments) { + e.hasMoreReply = (e.commCount ?? 0) > (e.replies?.length ?? 0); + } + } catch (e) { + refreshCtr?.refreshCompleted(); + refreshCtr?.loadComplete(); + debugLog(e); + } + update(); + } + + /// 评论区置顶 Banner(scene=COMMENT_TOP) + Future _fetchCommentTopBanners() async { + try { + final list = await CommonService.fetchBannerList(scene: 'COMMENT_TOP'); + commentTopBanners + ..clear() + ..addAll(list); + update(); + } catch (e) { + debugLog(e); + } + } + + /// 首条置顶评论下标:官方评论或带内外链的置顶样式评论 + int get topPinnedCommentIndex { + for (int i = 0; i < comments.length; i++) { + final c = comments[i]; + if (c.isOfficial || c.isHideLikeOrComment) return i; + } + return -1; + } +} diff --git a/lib/hj_page/comment/comment_views.dart b/lib/hj_page/comment/comment_views.dart new file mode 100644 index 0000000..f9b6f6e --- /dev/null +++ b/lib/hj_page/comment/comment_views.dart @@ -0,0 +1,131 @@ +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/comment/cell/comment_item.dart'; +import 'package:hgdj/hj_page/comment/comment_mixin.dart'; +import 'package:hgdj/hj_page/comment/widget/comment_foot_view.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../home/widget/quick_search_view.dart'; + +class CommentLogic extends GetxController with CommentMixin { + final String objId; + + final String commentType; + + @override + final Function(int)? totalCallback; + + CommentLogic( + this.objId, { + this.commentType = 'video', + this.totalCallback, + }) : super(); + + bool isLoading = true; + + @override + void onInit() { + super.onInit(); + id = objId; + objType = commentType; + } + + @override + void onReady() { + super.onReady(); + isLoading = false; + fetchComments(objId); + } +} + +class CommentView extends StatelessWidget { + final bool showInput; + final String objId; + final String objType; // video, cartoon + final Function(int)? totalCallback; + + const CommentView( + this.objId, { + super.key, + this.showInput = true, + this.objType = 'video', + this.totalCallback, + }); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: CommentLogic(objId, + commentType: objType, totalCallback: totalCallback), + tag: objId, + builder: (logic) => LayoutBuilder(builder: (_, cons) { + if (cons.maxHeight < 58) return SizedBox.shrink(); + return Column( + children: [ + Expanded( + child: pullYsRefresh( + onInit: (refreshCtr) => logic.refreshCtr = refreshCtr, + onLoading: (_) => logic.fetchComments(objId), + onRefresh: (_) => logic.fetchComments(objId, isRefresh: true), + child: () { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.comments.isEmpty) return CErrorWidget(); + return CustomScrollView( + slivers: [ + if (logic.quickSearchList.isNotEmpty) + SliverToBoxAdapter( + child: Container( + padding: EdgeInsets.only(bottom: 12), + margin: EdgeInsets.fromLTRB(12, 0, 12, 12), + decoration: BoxDecoration( + border: Border( + bottom: + BorderSide(color: Color(0x08ffffff)))), + child: QuickSearchView(logic.quickSearchList), + ), + ), + SliverList.builder( + itemCount: logic.comments.length, + itemBuilder: (BuildContext context, int index) { + final topIndex = logic.topPinnedCommentIndex; + final showBanner = index == topIndex && + logic.commentTopBanners.isNotEmpty; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: CommentItem( + key: ValueKey(logic.comments[index].id), + logic.comments[index], + objId: objId, + topBanners: + showBanner ? logic.commentTopBanners : null, + replyHandler: (comment, {reply}) => + logic.readyReplyComment(comment, + replyModel: reply), + ), + ); + }, + ), + ], + ); + }(), + ), + ), + if (showInput) + Obx( + () => CommentFootView( + isSending: logic.isSendingMsg.value, + onComment: (isSend) => + logic.readyReplyComment(null, isSend: isSend), + textCtr: logic.commentController, + imgPath: logic.selectedImagePath.value, + onImgChange: (value) => logic.selectedImagePath.value = value, + ), + ), + ], + ); + }), + global: false, + ); + } +} diff --git a/lib/hj_page/comment/widget/comment_foot_view.dart b/lib/hj_page/comment/widget/comment_foot_view.dart new file mode 100644 index 0000000..0bd4d8e --- /dev/null +++ b/lib/hj_page/comment/widget/comment_foot_view.dart @@ -0,0 +1,191 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/group_text_filed.dart'; +import 'package:image_pickers/image_pickers.dart'; + +import '../../../hj_utils/api_service/mine_service.dart'; +import '../../mine/collec_history_buy/col_his_buy_sub_page.dart'; + +/// 底部评论条:假输入框 + 选图 + 收藏 + 发送 +class CommentFootView extends StatefulWidget { + final VideoModel? video; + final Function(bool isSend) onComment; //false 点的输入框(唤起输入弹层),true 点的发送 + final bool isSending; + final TextEditingController textCtr; + final String? imgPath; + final Function(String?)? onImgChange; + final bool showCollect; + const CommentFootView({ + super.key, + this.video, + required this.onComment, + this.isSending = false, + required this.textCtr, + this.imgPath, + this.onImgChange, + this.showCollect = false, + }); + + @override + State createState() => _CommentFootViewState(); +} + +class _CommentFootViewState extends State { + bool _isCollectLoading = false; + + bool get isCollect => widget.video?.vidStatus?.hasCollected ?? false; + + void _pickImage() async { + final listMedia = await ImagePickers.pickerPaths( + uiConfig: UIConfig(uiThemeColor: Colors.white), + galleryMode: GalleryMode.image, + selectCount: 1, + showCamera: false, + ); + if (listMedia.isNotEmpty) + widget.onImgChange?.call(listMedia.first.path ?? ''); + if (mounted) setState(() {}); + } + + void _onCollect() async { + if (_isCollectLoading) return; + _isCollectLoading = true; + try { + final preCollect = isCollect; + + await MineService.postCollect( + widget.video?.id, LoadDataType.post.apiType, !isCollect); + + if (!preCollect) { + widget.video?.collectCount = (widget.video?.collectCount ?? 0) + 1; + } else { + widget.video?.collectCount = (widget.video?.collectCount ?? 1) - 1; + } + _isCollectLoading = false; + widget.video?.vidStatus?.hasCollected = !isCollect; + if (!preCollect) { + showToast("收藏成功"); + } + setState(() {}); + } catch (e) { + _isCollectLoading = false; + } + } + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black, + padding: EdgeInsets.only( + left: 18.w, right: 18.w, bottom: screen.paddingBottom), + alignment: Alignment.centerLeft, + child: SizedBox( + height: 58, + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => widget.onComment(false), + child: Container( + padding: EdgeInsets.symmetric(vertical: 2), + child: GroupTextFiled( + controller: widget.textCtr, + height: 32, + enabled: false, + padding: EdgeInsets.symmetric(horizontal: 16), + textStyle: TextStyle(color: Colors.white, fontSize: 14), + placeholder: '请在此输入评论...', + placeholderTextStyle: + TextStyle(color: Color(0xff525252), fontSize: 14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .1), + borderRadius: BorderRadius.circular(16)), + ), + ), + ), + ), + 12.sizeBoxW, + Stack( + children: [ + Container( + margin: EdgeInsets.fromLTRB(0, 4, 4, 4), + width: 30, + height: 30, + alignment: Alignment.center, + child: GestureDetector( + onTap: _pickImage, + child: widget.imgPath?.isNotEmpty == true + ? Image.file( + File(widget.imgPath!), + width: 30, + height: 30, + fit: BoxFit.cover, + ) + : Image.asset( + 'icon_pic.webp'.commentPath, + width: 24, + height: 24, + ), + ), + ), + if (widget.imgPath?.isNotEmpty == true) + Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: () { + widget.onImgChange?.call(null); + setState(() {}); + }, + child: Image.asset( + "close_grey.png".commonImgPath, + width: 12, + height: 12, + ), + ), + ), + ], + ), + if (widget.showCollect) + Container( + width: 30, + height: 30, + alignment: Alignment.center, + child: InkWell( + enableFeedback: false, + onTap: _onCollect, + child: Image.asset( + isCollect + ? "collect_red.png".commonImgPath + : "collect_path.png".commonImgPath, + width: 24, + ), + ), + ), + SizedBox( + width: 30, + height: 30, + child: GestureDetector( + onTap: () => widget.onComment(true), + child: widget.isSending + ? CupertinoActivityIndicator( + color: AppColors.actionRed, + radius: 12, + ) + : Image.asset('ic_send.webp'.commentPath), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/comment/widget/comment_rich_text.dart b/lib/hj_page/comment/widget/comment_rich_text.dart new file mode 100644 index 0000000..6823a25 --- /dev/null +++ b/lib/hj_page/comment/widget/comment_rich_text.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/comment/comment_model.dart'; +import 'package:hgdj/routers/jump_router.dart'; + +/// 评论正文:命中搜索词时把该词渲染成可点的橙色链接,其余按纯文本 +class CommentRichText extends StatelessWidget { + final CommentModel? model; + + const CommentRichText({super.key, this.model}); + + @override + Widget build(BuildContext context) { + const style = TextStyle(color: Color(0xE5FFFFFF), fontSize: 14); + final keyword = model?.searchKeyword ?? ""; + //按关键词切开,段与段之间插链接;切不出两段就当纯文本 + final parts = + keyword.isEmpty ? [] : (model?.content?.split(keyword) ?? []); + if (model?.linkStr?.isNotEmpty != true || + keyword.isEmpty || + parts.length <= 1) { + return Text(model?.content ?? '', style: style); + } + return Text.rich( + TextSpan( + style: style, + children: [ + for (int i = 0; i < parts.length; i++) ...[ + TextSpan(text: parts[i]), + if (i != parts.length - 1) + WidgetSpan( + child: GestureDetector( + onTap: () => pushToPageByLink(model?.linkStr), + child: Text( + keyword, + style: const TextStyle( + color: Color(0xffF68804), fontSize: 14, height: 1.2), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/hj_page/comment/widget/comment_top_banner.dart b/lib/hj_page/comment/widget/comment_top_banner.dart new file mode 100644 index 0000000..0a4a8ef --- /dev/null +++ b/lib/hj_page/comment/widget/comment_top_banner.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/banner/comment_top_banner_model.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/banner/ads_banner_widget.dart'; +import 'package:hgdj/tools_base/widget/card_swiper/src/swiper.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +/// 评论区置顶评论下方 Banner:支持多图轮播(含 GIF),按 linkType 跳转内外链 +class CommentTopBanner extends StatefulWidget { + final List banners; + final double borderRadius; + + /// 宽高比,默认对齐站内 Banner 素材 720×150;勿用固定高度,窄/宽屏 cover 会裁切 + final double aspectRatio; + + const CommentTopBanner({ + super.key, + required this.banners, + this.borderRadius = 12, + this.aspectRatio = 720 / 150, + }); + + @override + State createState() => _CommentTopBannerState(); +} + +class _CommentTopBannerState extends State { + final ValueNotifier _selectIndex = ValueNotifier(0); + + int get _count => widget.banners.length; + + @override + void dispose() { + _selectIndex.dispose(); + super.dispose(); + } + + Future _onTap(CommentTopBannerModel item) async { + final type = (item.linkType ?? '').toUpperCase(); + final value = item.linkValue?.trim() ?? ''; + if (type == 'NONE' || value.isEmpty) return; + + if (type == 'EXTERNAL') { + if (value.startsWith('http')) { + await launchUrlToWeb(value); + } else { + await pushToPageByLink(value); + } + return; + } + + if (type == 'INTERNAL') { + // 兼容 video://detail?id=xxx + final uri = Uri.tryParse(value); + if (uri != null && uri.scheme == 'video') { + final id = uri.queryParameters['id']; + if (id?.isNotEmpty == true) { + await pushToVideoPage(videoId: id); + return; + } + } + await pushToPageByLink(value); + } + } + + @override + Widget build(BuildContext context) { + if (_count == 0) return const SizedBox.shrink(); + return Container( + margin: const EdgeInsets.only(top: 8), + width: double.infinity, + child: AspectRatio( + aspectRatio: widget.aspectRatio, + child: ClipRRect( + borderRadius: BorderRadius.circular(widget.borderRadius), + child: Stack( + fit: StackFit.expand, + children: [ + Swiper( + autoplay: _count > 1, + autoplayDelay: 3000, + loop: _count > 1, + itemCount: _count, + onIndexChanged: (index) => _selectIndex.value = index, + itemBuilder: (context, index) { + final item = widget.banners[index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _onTap(item), + child: NetworkImageLoader( + imageUrl: item.imageUrl ?? '', + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + borderRadius: 0, + ), + ); + }, + ), + if (_count > 1) + Positioned( + bottom: 8, + left: 0, + right: 0, + child: Center( + child: ValueListenableBuilder( + valueListenable: _selectIndex, + builder: (_, index, __) => CIndicator( + itemCount: _count, + selectIndex: index, + space: 3, + dotSize: 4, + selectWidth: 10, + isBarStyle: true, + color: Colors.white.withValues(alpha: 0.4), + selectColor: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/community/community/community_module_logic.dart b/lib/hj_page/community/community/community_module_logic.dart new file mode 100644 index 0000000..b0d2f16 --- /dev/null +++ b/lib/hj_page/community/community/community_module_logic.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/config/config.dart'; +import '../../main_page/provider/bottom_bar_provider.dart'; + +class CommunityMainModuleLogic extends GetxController + with GetTickerProviderStateMixin { + final int defaultIndex; + final modules = []; + late final TabController tabController; + + CommunityMainModuleLogic(this.defaultIndex); + + @override + void onInit() { + super.onInit(); + modules.addAll(Config.plateModule?.community ?? []); + tabController = TabController( + length: modules.length, vsync: this, initialIndex: defaultIndex); + tabController.addListener(() { + if (!tabController.indexIsChanging) { + CommunityBottomProvider().subIndex = tabController.index; + } + }); + } + + void skipToTabIndex(int index) { + tabController.index = index; + } +} diff --git a/lib/hj_page/community/community/community_module_page.dart b/lib/hj_page/community/community/community_module_page.dart new file mode 100644 index 0000000..f7684a6 --- /dev/null +++ b/lib/hj_page/community/community/community_module_page.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../../../tools_base/indicator/custom_tab_indicator.dart'; +import '../../../tools_base/loading/loading_center_widget.dart'; +import '../../home/widget/publist_entry_alert.dart'; +import 'community_module_logic.dart'; +import 'community_tab_page.dart'; + +//社区帖子亚模块 +class CommunityModulePage extends StatelessWidget { + final int defaultIndex; + const CommunityModulePage({super.key, this.defaultIndex = 0}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: CommunityMainModuleLogic(defaultIndex), + builder: (logic) { + if (logic.modules.isEmpty) return CErrorWidget(); + return Stack( + children: [ + Column( + children: [ + TabBar( + labelPadding: EdgeInsets.zero, + padding: EdgeInsets.zero, + tabAlignment: TabAlignment.start, + indicator: CustomIndicator(offsetY: 6), + indicatorWeight: 1, + unselectedLabelStyle: + TextStyle(color: Color(0x8cFFFFFF), fontSize: 16), + labelStyle: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 16, + fontWeight: FontWeight.w500), + isScrollable: true, + tabs: logic.modules + .map( + (e) => Padding( + padding: EdgeInsets.symmetric( + horizontal: 16.w, vertical: 13.h), + child: Text(e.moduleName ?? ''), + ), + ) + .toList(), + controller: logic.tabController, + ), + Expanded( + child: TabBarView( + controller: logic.tabController, + children: logic.modules + .asMap() + .map( + (key, value) => MapEntry( + key, + CommunityTabPage( + model: value, + index: key, + cancelCallBack: () { + logic.tabController.index = + logic.tabController.previousIndex; + }, + ).keepAlive, + ), + ) + .values + .toList(), + ), + ) + ], + ), + Positioned( + right: 20, + bottom: 50, + child: PublishButton(entry: PublishEntry.community), + ), + ], + ); + }, + ); + } +} diff --git a/lib/hj_page/community/community/community_tab_logic.dart b/lib/hj_page/community/community/community_tab_logic.dart new file mode 100644 index 0000000..e4f38d3 --- /dev/null +++ b/lib/hj_page/community/community/community_tab_logic.dart @@ -0,0 +1,139 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/event_bus/event_bus_util.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../hj_model/home/plate_model.dart'; +import '../../home/widget/collect_status_wrapper.dart'; +import '../../main_page/provider/bottom_bar_provider.dart'; + +class CommunityTabLogic extends GetxController { + final ModuleData? model; + final int index; + + CommunityTabLogic(this.model, {this.index = 0}); + + RefreshController? refreshController; + late ScrollController sctrl = + CommunityBottomProvider().scrollCtr(MediaStyle.Community, index); + + int page = 1; + bool isLoading = true; + + /// 索引 + int sort = 0; + //标题与后端 moduleSort 绑在一起:原来标题在 CommunityMiddleHeader、值在这里, + //两个数组分家,任一边加减一项就会整体错位 + static const sortTabs = [ + SortTab('推荐', 7), + SortTab('最新', 1), + SortTab('最热', 2), + SortTab('热评', 9), + ]; + int? tagIndex; + String? tagId; + List dataSource = []; + + /// 顶部的专题 + final List specials = []; + + StreamSubscription? _collectStatusSub; + + void _onCollectStatusChanged(CollectStatusModel event) { + if (event.isLiked == null && event.likeCountDelta == null) return; + var changed = false; + for (final item in dataSource) { + if (item.id == event.id) { + applyVideoModelCollectStatus(item, event); + changed = true; + } + } + if (changed) update(); + } + + @override + void onInit() { + super.onInit(); + _collectStatusSub = + eventBus.on(_onCollectStatusChanged); + } + + @override + void onClose() { + _collectStatusSub?.cancel(); + super.onClose(); + } + + @override + void onReady() { + super.onReady(); + tagId = model?.defaultTagId; + fetchPagedata(); + } + + void changeSpecialsIndex(int index) { + if (tagIndex == index) { + tagId = null; + tagIndex = null; + } else { + tagIndex = index; + tagId = specials[index].id; + } + fetchPagedata(needLoading: true); + } + + void scrollerToTop() { + sctrl.jumpTo(0); + } + + Future fetchPagedata( + {bool isRefresh = true, bool needLoading = false}) async { + if (isRefresh) { + page = 1; + } + if (needLoading) { + isLoading = true; + update(); + } + try { + final res = await VidService.getModuleDetail( + model?.id ?? '', + pageNumber: page, + moduleSort: sortTabs[sort].sort, + tagId: tagId, + ); + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + (res?.hasNext ?? false) + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(res?.allVideoInfo ?? []); + if ((res?.allSection?.isNotEmpty ?? false) && specials.isEmpty) { + specials.addAll(res!.allSection?.firstOrNull?.allTags ?? []); + if (model?.defaultTagId?.isNotEmpty == true) { + tagIndex = specials + .indexWhere((element) => element.id == model?.defaultTagId); + } + } + page += 1; + } catch (e) { + // 接口异常兜底:结束刷新/加载态,避免 loading、下拉/上拉指示器卡死 + debugLog(e); + isRefresh + ? refreshController?.refreshCompleted() + : refreshController?.loadComplete(); + } finally { + isLoading = false; + update(); + } + } +} diff --git a/lib/hj_page/community/community/community_tab_page.dart b/lib/hj_page/community/community/community_tab_page.dart new file mode 100644 index 0000000..a4d691a --- /dev/null +++ b/lib/hj_page/community/community/community_tab_page.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../../../hj_model/home/plate_model.dart'; +import '../../../hj_model/video_model.dart'; +import '../../../hj_utils/sliver_delegate.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import '../widget/community_middle_header.dart'; +import '../widget/community_post_widget.dart'; +import 'community_tab_logic.dart'; +import 'package:hgdj/extension/extensions.dart'; + +class CommunityTabPage extends StatelessWidget { + final ModuleData model; + final int index; + final Function()? cancelCallBack; + + const CommunityTabPage({ + super.key, + required this.model, + this.index = 0, + this.cancelCallBack, + }); + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: 'community_tab_${model.id ?? index}', + init: CommunityTabLogic(model, index: index), + builder: (logic) { + return pullYsRefresh( + onInit: (ctr) => logic.refreshController = ctr, + onRefresh: (_) => logic.fetchPagedata(), + onLoading: (_) => logic.fetchPagedata(isRefresh: false), + child: CustomScrollView( + controller: logic.sctrl, + slivers: [ + if (model.pureVersion != true) + SliverToBoxAdapter( + child: AdsGridViewWidget( + 7, + accordingAdsType: true, + padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 10.h), + ), + ), + SliverToBoxAdapter( + child: _buildSpecialView(logic), + ), + SliverPersistentHeader( + pinned: true, + floating: true, + delegate: MySliverDelegate( + maxHeight: 34, + minHeight: 34, + child: CommunityMiddleHeader( + sortOnTap: (sort) { + logic.sort = sort; + logic.fetchPagedata(needLoading: true); + }, + ), + ), + ), + _buildTableView(logic), + ], + ), + ); + }, + ); + } + + Widget _buildSpecialView(CommunityTabLogic logic) { + if (logic.specials.isEmpty) return SizedBox.shrink(); + return GridView.builder( + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.only(left: 16.w, right: 16.w, bottom: 18.h), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 7, + crossAxisSpacing: 7, + childAspectRatio: 120 / 57, + ), + itemCount: logic.specials.length, + shrinkWrap: true, + itemBuilder: (BuildContext context, int index) { + return InkWell( + enableFeedback: false, + onTap: () => logic.changeSpecialsIndex(index), + child: _buildSpecialTagCell(logic, index), + ); + }, + ); + } + + Widget _buildSpecialTagCell(CommunityTabLogic logic, int index) { + TagsBean model = logic.specials[index]; + return Stack( + fit: StackFit.expand, + children: [ + Container( + decoration: logic.tagIndex == index + ? BoxDecoration( + color: Color(0xff303030), + borderRadius: BorderRadius.all(Radius.circular(6)), + border: Border.all( + color: AppColors.actionRed, + width: 1, + )) + : BoxDecoration( + color: Color(0xff303030), + ), + padding: EdgeInsets.only(bottom: 0), + child: Stack( + fit: StackFit.expand, + children: [ + NetworkImageLoader( + imageUrl: model.coverImg ?? '', + borderRadius: 6, + width: double.infinity, + height: double.infinity, + ), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Colors.black.withValues(alpha: .6)), + ), + if (model.hotMark?.isNotEmpty == true) + Positioned( + top: 0, + left: 0, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 3, vertical: 1), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color.fromRGBO(252, 118, 118, 1), + Color.fromRGBO(212, 39, 39, 1), + ], + ), + borderRadius: BorderRadius.only( + bottomRight: Radius.circular(6), + topLeft: Radius.circular(6), + ), + ), + child: Text( + "${model.hotMark}", + style: TextStyle(color: Colors.white, fontSize: 11), + ), + ), + ), + ], + ), + ), + Stack( + fit: StackFit.expand, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${model.tagName}", + style: TextStyle( + color: logic.tagIndex == index + ? AppColors.actionRed + : Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + 2.sizeBoxH, + Text( + "${model.videoCount?.countStr}个帖子", + style: TextStyle( + color: logic.tagIndex == index + ? AppColors.actionRed + : Colors.white, + fontSize: 12), + ), + ], + ), + ], + ), + ], + ); + } + + Widget _buildTableView(CommunityTabLogic logic) { + if (logic.isLoading) { + return SliverToBoxAdapter( + child: SizedBox( + height: 300, + child: LoadingCenterWidget(), + ), + ); + } + if (logic.dataSource.isEmpty) { + return SliverToBoxAdapter( + child: SizedBox( + height: 300, + child: CErrorWidget( + retryOnTap: () => logic.fetchPagedata(), + ), + ), + ); + } + + return SliverList.builder( + itemCount: logic.dataSource.length, + itemBuilder: (BuildContext context, int index) { + VideoModel videoModel = logic.dataSource[index]; + return CommunityPostWidget( + videoModel: videoModel, + videoModels: logic.dataSource, + showLine: true, + ); + }, + ); + } +} diff --git a/lib/hj_page/community/community_main_page_page/community_main_logic.dart b/lib/hj_page/community/community_main_page_page/community_main_logic.dart new file mode 100644 index 0000000..d0b04c5 --- /dev/null +++ b/lib/hj_page/community/community_main_page_page/community_main_logic.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/const.dart'; + +import '../../main_page/main_logic.dart'; +import '../../main_page/provider/bottom_bar_provider.dart'; + +class CommunityMainLogic extends GetxController + with GetTickerProviderStateMixin { + final int index; + + CommunityMainLogic({this.index = 0}); + + List tabs = [ + MainTabModel( + title: '社区', + imageNor: 'community_main_sel.png'.communityPath, + imageSel: 'community_main_sel.png'.communityPath, + ), + MainTabModel( + title: '色图', + imageNor: 'community_pic_sel.png'.communityPath, + imageSel: 'community_pic_sel.png'.communityPath, + ), + MainTabModel( + title: '小说', + imageNor: 'community_noval_sel.png'.communityPath, + imageSel: 'community_noval_sel.png'.communityPath, + ), + ]; + + // tab 顺序对应的媒体类型:社区/色图/小说 + static const List _tabTypes = [ + MediaStyle.Community, + MediaStyle.Pic, + MediaStyle.Novel + ]; + + late TabController tabCtr; + + @override + void onInit() { + tabCtr = + TabController(length: tabs.length, vsync: this, initialIndex: index); + tabCtr.addListener(() { + if (tabCtr.indexIsChanging) return; + CommunityBottomProvider().communityType = _tabTypes[tabCtr.index]; + }); + super.onInit(); + } + + // 跳转相关模块的亚模块 + void jumpSubModule(int index, {int tabIndex = 0}) { + tabCtr.index = index; + } +} diff --git a/lib/hj_page/community/community_main_page_page/community_main_page.dart b/lib/hj_page/community/community_main_page_page/community_main_page.dart new file mode 100644 index 0000000..57b4c81 --- /dev/null +++ b/lib/hj_page/community/community_main_page_page/community_main_page.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../../../config/config.dart'; +import '../../cartoon/cartoon_sub_module_page.dart'; +import '../../home/search_page/search_main_page.dart'; +import '../../mine/welfare/sign_daily_page.dart'; +import '../community/community_module_page.dart'; +import 'community_main_logic.dart'; +import 'community_section_tab.dart'; + +class CommunityMainPage extends StatefulWidget { + final int index; + final int subTabIndex; + + CommunityMainPage({super.key, this.index = 0, this.subTabIndex = 0}); + + @override + State createState() { + return CommunityMainPageState(); + } +} + +class CommunityMainPageState extends State + with SingleTickerProviderStateMixin { + @override + void initState() { + super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + //提前加载图片资源 + precacheImage(AssetImage('community_main_sel.png'.communityPath), context); + precacheImage(AssetImage('community_pic_sel.png'.communityPath), context); + precacheImage( + AssetImage('community_chatgroup_sel.png'.communityPath), context); + precacheImage(AssetImage('community_noval_sel.png'.communityPath), context); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + Scaffold( + backgroundColor: Colors.transparent, + body: GetBuilder( + init: CommunityMainLogic(index: widget.index), + builder: (_) => Column( + children: [ + screen.paddingTop.sizeBoxH, + Row( + children: [ + Expanded(child: _buildTabbar(_)), + if (Config.signIcon != null && + Config.signIcon!.isNotEmpty) ...[ + InkWell( + enableFeedback: false, + onTap: () => Get.to(SignDailyPage()), + child: Image.asset( + "community_sign.webp".communityPath, + width: 20, + ), + ), + ], + 12.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: () => Get.to(SearchMainPage()), + child: Image.asset( + 'search_icon.png'.commonImgPath, + width: 24, + color: Color(0xff989898), + ), + ), + 16.sizeBoxW, + ], + ), + Expanded( + child: TabBarView( + controller: _.tabCtr, + children: [ + //社区 + CommunityModulePage( + defaultIndex: + widget.index == 0 ? widget.subTabIndex : 0, + ).keepAlive, + //图集 + CartoonSubModulePage( + type: MediaStyle.Pic, + tabIndex: widget.index == 1 ? widget.subTabIndex : 0, + ).keepAlive, + // CartoonSubModulePage( + // type: MediaStyle.Game, + // tabIndex: widget.index == 2 ? widget.subTabIndex : 0, + // ).keepAlive, + //小说 + CartoonSubModulePage( + type: MediaStyle.Novel, + tabIndex: widget.index == 3 ? widget.subTabIndex : 0, + ).keepAlive, + ], + ), + ) + ], + ), + ), + ), + ], + ); + } + + _buildTabbar(CommunityMainLogic _) { + return CommunitySectionTabMenu( + _.tabs, + curIndex: widget.index, + tabCtr: _.tabCtr, + ); + } + + @override + void dispose() { + // Get.delete(); + super.dispose(); + } +} diff --git a/lib/hj_page/community/community_main_page_page/community_section_tab.dart b/lib/hj_page/community/community_main_page_page/community_section_tab.dart new file mode 100644 index 0000000..1b03a8e --- /dev/null +++ b/lib/hj_page/community/community_main_page_page/community_section_tab.dart @@ -0,0 +1,85 @@ +//发现模块tabbar +import 'package:flutter/material.dart'; + +import '../../../hj_utils/widget_util.dart'; +import '../../main_page/main_logic.dart'; + +class CommunitySectionTabMenu extends StatefulWidget { + final int curIndex; + final List tabs; + final TabController? tabCtr; + final double? fontSize; + + const CommunitySectionTabMenu( + this.tabs, { + super.key, + this.curIndex = 0, + this.tabCtr, + this.fontSize = 16, + }); + + @override + State createState() { + return _CommunitySectionTabMenuState(); + } +} + +class _CommunitySectionTabMenuState extends State { + late int curIndex = widget.curIndex; + + @override + void initState() { + super.initState(); + widget.tabCtr?.addListener(_listener); + } + + void _listener() { + setState(() => curIndex = widget.tabCtr?.index ?? 0); + } + + @override + void dispose() { + widget.tabCtr?.removeListener(_listener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 36, + child: TabBar( + tabAlignment: TabAlignment.start, + padding: const EdgeInsets.only(left: 6), + controller: widget.tabCtr, + indicatorWeight: 0, + isScrollable: true, + labelPadding: const EdgeInsets.symmetric(horizontal: 8), + labelStyle: TextStyle(fontSize: widget.fontSize, fontWeight: FontWeight.w600), + unselectedLabelStyle: TextStyle(fontSize: widget.fontSize, fontWeight: FontWeight.w400), + indicator: const BoxDecoration(), + tabs: List.generate(widget.tabs.length, _tabItem), + ), + ); + } + + Widget _tabItem(int index) { + final sel = curIndex == index; + // 固定 49 宽槽位,选中(图标)/未选中(文字)就地交叉淡入淡出,宽度恒定不跳动 + return AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: SizedBox( + key: ValueKey(sel), + width: 49, + child: sel + ? Center(child: Image.asset(widget.tabs[index].imageNor, height: 31)) + : Align( + alignment: Alignment(0, -0.5), + child: Text( + widget.tabs[index].title, + style: textStyle(14, Colors.white.withValues(alpha: 0.55), FontWeight.w400), + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/community/community_tag_page/community_tag_logic.dart b/lib/hj_page/community/community_tag_page/community_tag_logic.dart new file mode 100644 index 0000000..bf38130 --- /dev/null +++ b/lib/hj_page/community/community_tag_page/community_tag_logic.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/tag_service.dart'; + +class CommunityTagLogic extends GetxController + with GetTickerProviderStateMixin { + final scrollCtr = ScrollController(); + double? offset; + bool isPinned = false; + TagsBean model; + StreamController showAppCtr = StreamController.broadcast(); + late final TabController mainTabCtr = + TabController(length: sorts.length, vsync: this); + + RxBool isLoadingFollow = false.obs; + final sorts = [2, 1, 5]; //排序类型 1:最新上架 2:最多收藏 3:本月最热 4:最多观看 + + CommunityTagLogic(this.model); + + @override + onReady() async { + super.onReady(); + final newsType = model.newsType; + final res = await TagService.fetchInfo(model.id ?? ''); + if (res != null) { + model = res; + model.newsType = newsType; + } + update(); + } + + @override + void onClose() { + showAppCtr.close(); + + mainTabCtr.dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/community/community_tag_page/community_tag_page.dart b/lib/hj_page/community/community_tag_page/community_tag_page.dart new file mode 100644 index 0000000..cd2a437 --- /dev/null +++ b/lib/hj_page/community/community_tag_page/community_tag_page.dart @@ -0,0 +1,160 @@ +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../tools_base/widget/follow_button.dart'; +import '../../home/widget/collect_status_wrapper.dart'; +import '../widget/community_sort_header.dart'; +import 'community_tag_logic.dart'; +import 'subviews/tag_detail_sub_controller.dart'; +import 'subviews/tag_detail_subview.dart'; + +class UserCenterSortTypeEvent { + int sortIndex; + UserCenterSortTypeEvent(this.sortIndex); +} + +class CommunityTagDetailPage extends StatefulWidget { + final TagsBean model; + + const CommunityTagDetailPage({super.key, required this.model}); + + @override + State createState() => _CommunityTagDetailPageState(); +} + +class _CommunityTagDetailPageState extends State + with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: CommunityTagLogic(widget.model), + builder: (controller) => Scaffold( + extendBodyBehindAppBar: true, + body: Stack( + children: [ + ExtendedNestedScrollView( + controller: controller.scrollCtr, + pinnedHeaderSliverHeightBuilder: () => + AppBar().preferredSize.height + screen.paddingTop, + headerSliverBuilder: (_, istop) { + return [ + SliverAppBar( + pinned: true, + floating: false, + expandedHeight: 64, + titleSpacing: 18.w, + automaticallyImplyLeading: false, + title: _buildAppBarTitle(controller), + flexibleSpace: FlexibleSpaceBar( + background: SizedBox(), + ), + ) + ]; + }, + body: Column( + children: [ + 12.sizeBoxH, + CommunitySortHeader( + controller: controller.mainTabCtr, + sortOnTap: (index) {}, + ), + 12.sizeBoxH, + Expanded( + child: TabBarView( + controller: controller.mainTabCtr, + children: controller.sorts + .map( + (e) => TagDetailSubView( + tag: controller.model, postSort: e) + .keepAlive, + ) + .toList(), + ), + ) + ], + ), + ) + ], + ), + ), + ); + } + + Widget _buildAppBarTitle(CommunityTagLogic controller) { + return ChangeNotifierProvider.value( + value: controller.scrollCtr, + child: Consumer( + builder: (_, ctr, __) { + final isTop = (180 - ctr.offset - kToolbarHeight) == 0; + return Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Image.asset( + isTop + ? 'common_back.png'.commonImgPath + : 'back_circle.png'.commonImgPath, + width: 24, + height: 24, + ), + ), + if (isTop) ...[ + 4.sizeBoxW, + NetworkImageLoader( + imageUrl: controller.model.coverImg ?? '', + width: 30, + height: 30, + borderRadius: 15, + ), + 6.sizeBoxW, + Text( + controller.model.name ?? '', + style: const TextStyle( + fontSize: 14, + color: Color(0xffffffff), + fontWeight: FontWeight.w500, + ), + ), + Spacer(), + CollectStatusWrapper( + tagModel: controller.model, + builder: () => FollowButton( + mediaId: controller.model.id, + isFollow: controller.model.hasCollected ?? false, + followType: FollowEnum.tag, + successsAction: (success) => + controller.model.hasCollected = success, + ), + ), + ] else ...[ + Expanded( + child: Center( + child: Text( + controller.model.name ?? '', + style: TextStyle( + fontSize: 18, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + (24 + 18.w).sizeBoxH, + ], + ], + ); + }, + ), + ); + } +} diff --git a/lib/hj_page/community/community_tag_page/subviews/tag_detail_sub_controller.dart b/lib/hj_page/community/community_tag_page/subviews/tag_detail_sub_controller.dart new file mode 100644 index 0000000..19fec93 --- /dev/null +++ b/lib/hj_page/community/community_tag_page/subviews/tag_detail_sub_controller.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/tag_service.dart'; +import 'package:hgdj/tools_base/event_bus/event_bus_util.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../home/widget/collect_status_wrapper.dart'; + +abstract class TagDetailSubController extends GetxController { + List dataSource = []; + bool isGridStyle = true; + double childRatio = 1; + bool isLoading = true; + String? loadType; + String? newsType; + String? sort; + int? postSort; + int page = 1; + TagsBean tag; + + TagDetailSubController(this.tag, {this.postSort}); + + RefreshController? controller; + + StreamSubscription? _collectStatusSub; + + void _onCollectStatusChanged(CollectStatusModel event) { + if (event.isLiked == null && event.likeCountDelta == null) return; + var changed = false; + for (final item in dataSource) { + if (item.id == event.id) { + applyVideoModelCollectStatus(item, event); + changed = true; + } + } + if (changed) update(); + } + + @override + void onInit() { + super.onInit(); + _collectStatusSub = + eventBus.on(_onCollectStatusChanged); + } + + @override + void onClose() { + _collectStatusSub?.cancel(); + super.onClose(); + } + + @override + void onReady() { + super.onReady(); + // isLoading = false; + fetchPageData(needLoading: true); + } + + fetchPageData({bool isRefresh = true, bool needLoading = false}) async { + if (isRefresh) { + page = 1; + } + + isLoading = needLoading; + update(); + final res = await TagService.fetchList( + tag.id ?? '', + newsType: newsType, + sortType: postSort, + page: page, + size: 10, + ); + if (isRefresh) { + dataSource.clear(); + controller?.refreshCompleted(); + } + + dataSource.addAll(res?.videos ?? []); + page += 1; + isLoading = false; + update(); + res?.hasNext ?? false + ? controller?.loadComplete() + : controller?.loadNoData(); + } +} + +class TagVideoController extends TagDetailSubController { + TagVideoController(super.tag); + + @override + String get loadType => 'MOVIE'; + String get newsType => tag.newsType ?? "MOVIE"; + + @override + double get childRatio => 168 / 119; +} + +class TagSPController extends TagDetailSubController { + TagSPController(super.tag); + @override + String get loadType => 'SP'; + String get newsType => tag.newsType ?? "SP"; + + @override + double get childRatio => 168 / 248; +} + +class TagCOVERController extends TagDetailSubController { + TagCOVERController(super.tag, {super.postSort}); + + @override + String? get loadType => null; + String get newsType => tag.newsType ?? ""; + + @override + bool get isGridStyle => false; +} + +T instanceController(TagsBean tag, {int? postSort}) { + // Type 对象比较,避免 release 混淆下类名字符串匹配失败导致 throw→灰屏 + if (T == TagCOVERController) + return TagCOVERController(tag, postSort: postSort) as T; + if (T == TagSPController) return TagSPController(tag) as T; + if (T == TagVideoController) return TagVideoController(tag) as T; + throw '$T 没有对应类型'; +} diff --git a/lib/hj_page/community/community_tag_page/subviews/tag_detail_subview.dart b/lib/hj_page/community/community_tag_page/subviews/tag_detail_subview.dart new file mode 100644 index 0000000..ba9917d --- /dev/null +++ b/lib/hj_page/community/community_tag_page/subviews/tag_detail_subview.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../../../home/home_cell_style/video_simple_cell.dart'; +import '../../widget/community_post_widget.dart'; +import 'tag_detail_sub_controller.dart'; + +class TagDetailSubView + extends StatelessWidget { + final TagsBean tag; + + final int? postSort; + + const TagDetailSubView({super.key, required this.tag, this.postSort}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: instanceController(tag, postSort: postSort), + global: false, + builder: (controller) { + if (controller.isLoading) return LoadingCenterWidget(); + return pullYsRefresh( + onLoading: (refreshController) => + controller.fetchPageData(isRefresh: false), + onRefresh: (refreshController) => controller.fetchPageData(), + child: () { + if (controller.dataSource.isEmpty) return CErrorWidget(); + if (controller.isGridStyle) + return GridView.builder( + padding: EdgeInsets.symmetric(horizontal: 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: controller.childRatio, + crossAxisSpacing: 7, + mainAxisSpacing: 12, + ), + itemCount: controller.dataSource.length, + itemBuilder: (BuildContext context, int index) { + final model = controller.dataSource[index]; + return GestureDetector( + onTap: () => pushToVideoPage(videoModel: model), + child: VideoSimpleCell(videoModel: model), + ); + }, + ); + return ListView.separated( + padding: EdgeInsets.only(top: 0), + separatorBuilder: (_, __) => 14.sizeBoxH, + itemCount: controller.dataSource.length, + itemBuilder: (BuildContext context, int index) { + final model = controller.dataSource[index]; + return CommunityPostWidget( + videoModel: model, + videoModels: controller.dataSource, + showTags: false, + ); + }, + ); + }(), + onInit: (ctr) => controller.controller = ctr, + ); + }, + ); + } +} diff --git a/lib/hj_page/community/group_chat/alert/group_buy_bottom_sheet.dart b/lib/hj_page/community/group_chat/alert/group_buy_bottom_sheet.dart new file mode 100644 index 0000000..f0addf7 --- /dev/null +++ b/lib/hj_page/community/group_chat/alert/group_buy_bottom_sheet.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/pay/pay_manager.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; +import 'package:provider/provider.dart'; + +import '../model/im_group_list_model.dart'; + +class GroupBuyBottomSheet extends StatefulWidget { + final IMGroupItemModel groupModel; + + const GroupBuyBottomSheet({super.key, required this.groupModel}); + + @override + State createState() => _GroupBuyBottomSheetState(); +} + +class _GroupBuyBottomSheetState extends State { + @override + void initState() { + super.initState(); + globalStore.updateUserInfo(); + globalStore.refreshWallet(); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (_, store, __) { + final enough = + (store.wallet?.amount ?? 0) >= (widget.groupModel.price ?? 0); + return _buildNormal(enough, store); + }, + ); + } + + Widget _buildNormal(bool enough, GlobalStore store) { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 18), + decoration: BoxDecoration( + color: AppColors.primaryColor, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SheetHandleBar(), + 18.sizeBoxH, + Text( + '购买', + style: TextStyle( + color: Color(0xE5ffffff), + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + 36.sizeBoxH, + Row( + children: [ + Text( + '解锁群聊', + style: TextStyle( + color: Color(0xE5ffffff), + fontSize: 14, + ), + ), + Spacer(), + GestureDetector( + onTap: () => pushToWalletPage(tabPosition: 1), + child: Row( + children: [ + Text( + '${widget.groupModel.price ?? 0} 金币', + style: + TextStyle(color: AppColors.actionRed, fontSize: 14), + ), + ], + ), + ) + ], + ), + 18.sizeBoxH, + 0.5.line, + 18.sizeBoxH, + Row( + children: [ + Text( + '我的金币', + style: TextStyle( + color: Color(0xE5ffffff), + fontSize: 14, + ), + ), + Spacer(), + Row( + children: [ + Text( + '${GlobalStore().wallet?.amount?.toDouble().fixed(2) ?? 0}', + style: TextStyle( + color: Color(0xE5ffffff), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + 12.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: () => pushToWalletPage(tabPosition: 1), + child: Text( + '充值', + style: TextStyle( + color: Color(0xffA4634D), + fontSize: 14, + fontWeight: FontWeight.w400, + decoration: TextDecoration.underline, + decorationColor: Color(0xffA4634D), + ), + ), + ), + ], + ), + ], + ), + 18.sizeBoxH, + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => _doPayAction(enough), + child: Container( + width: double.infinity, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed, + ), + alignment: Alignment.center, + child: Text( + enough + ? '${widget.groupModel.price ?? 0} 金币/立即支付' + : '余额不足,前往充值', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + ), + ), + ], + ), + ); + } + + void _doPayAction(bool enough) async { + if (!enough) { + await pushToWalletPage(tabPosition: 1); + return; + } + await PayManager().buy( + widget.groupModel.id, + ProductType.group, + source: 'group_buy', + onSuccess: (data) { + globalStore.refreshWallet(); + Get.back(result: true); + }, + ); + } +} diff --git a/lib/hj_page/community/group_chat/detail/soul_group_detail_logic.dart b/lib/hj_page/community/group_chat/detail/soul_group_detail_logic.dart new file mode 100644 index 0000000..80bc281 --- /dev/null +++ b/lib/hj_page/community/group_chat/detail/soul_group_detail_logic.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../../hj_utils/api_service/group_service.dart'; +import '../../../../tools_base/file_upload/file_upload_tool.dart'; +import '../../../../tools_base/file_upload/upload_result_model.dart'; +import '../../../comment/comment_input_view.dart'; +import '../model/im_group_list_model.dart'; +import '../model/im_message_resp.dart'; + +class GroupChatDetailLogic extends GetxController { + final IMGroupItemModel model; + + GroupChatDetailLogic(this.model); + + int pageNum = 1; + List? msgList; + RefreshController? refreshController; + TextEditingController textEditController = TextEditingController(); + Rx selectedImagePath = Rx(null); + RxBool isSendingMsg = false.obs; + + @override + void onReady() { + refreshData(); + super.onReady(); + } + + void refreshData() { + _loadData(); + } + + void loadMoreData() { + _loadData(page: pageNum + 1); + } + + void _loadData({int page = 1, int size = 20}) async { + try { + final retResp = await GroupService.getMessages(model.groupId, page, size); + pageNum = page; + msgList ??= []; + if (pageNum == 1) { + msgList?.clear(); + } + msgList?.addAll(retResp.list ?? []); + retResp.hasNext == true + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + } catch (e) { + debugLog(e); + refreshController?.loadComplete(); + } + refreshController?.refreshCompleted(); + update(); + } + + Future readySendMsg({bool? isSend}) async { + if (isSend == true && + (textEditController.text.isNotEmpty || + selectedImagePath.value?.isNotEmpty == true)) { + // 点击发送按钮有内容,直接发送内容, 否则弹出输入框 + sendMsgEvent(); + } else { + final result = await Get.bottomSheet( + CommentInputView( + hint: "请输入消息", + textCtr: textEditController, + imgPath: selectedImagePath.value, + onImgChange: (value) => selectedImagePath.value = value, + ), + backgroundColor: Colors.transparent, + barrierColor: Colors.transparent, + enableDrag: false, + settings: RouteSettings(name: CommentInputView.routeName), + useRootNavigator: true, + ); + if (result == true) { + update(); + } + sendMsgEvent(); + } + } + + Future sendMsgEvent() async { + if (textEditController.text.isEmpty && + selectedImagePath.value?.isNotEmpty != true) { + showToast("请输入消息内容"); + return; + } + if (isSendingMsg.value) return; + isSendingMsg.value = true; + String? sendImagePath; + if (selectedImagePath.value?.isNotEmpty == true) { + try { + ImageUploadResultModel? imgResult = + await FileUploadTool().uploadImage(selectedImagePath.value!); + sendImagePath = imgResult?.coverImg ?? ""; + if (sendImagePath.isEmpty) { + isSendingMsg.value = false; + showToast("图片上传失败!"); + return; + } + } catch (e) { + debugLog(e); + isSendingMsg.value = false; + showToast("图片上传失败!"); + return; + } + } + try { + var ret = await GroupService.sendMessage( + model.groupId, textEditController.text, sendImagePath); + if (ret.isSuccess) { + textEditController.text = ""; + selectedImagePath.value = null; + showToast("消息已发送"); + if (ret.data is Map && (ret.data['message'] is Map)) { + IMMessageModel sendModel = + IMMessageModel.fromJson(ret.data['message']); + sendModel.portrait = globalStore.meInfo?.portrait; + sendModel.name = globalStore.meInfo?.name; + msgList?.insert(0, sendModel); + update(); + } + } + } catch (e) { + showToast("消息发送失败!"); + debugLog(e); + } + isSendingMsg.value = false; + } +} diff --git a/lib/hj_page/community/group_chat/detail/soul_group_detail_page.dart b/lib/hj_page/community/group_chat/detail/soul_group_detail_page.dart new file mode 100644 index 0000000..4456262 --- /dev/null +++ b/lib/hj_page/community/group_chat/detail/soul_group_detail_page.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/comment/widget/comment_foot_view.dart'; + +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../model/im_group_list_model.dart'; +import 'soul_group_detail_logic.dart'; +import 'widget/chat_item_cell.dart'; + +class GroupChatDetailPage extends StatelessWidget { + final IMGroupItemModel model; + + const GroupChatDetailPage(this.model, {super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + global: false, + init: GroupChatDetailLogic(model), + builder: (logic) { + return Scaffold( + appBar: AppBar( + title: Text(model.name ?? ""), + ), + body: Column( + children: [ + Expanded( + child: pullYsRefresh( + onInit: (ctr) => logic.refreshController = ctr, + onRefresh: (ctr) => logic.refreshData(), + onLoading: (ctr) => logic.loadMoreData(), + child: ListView.builder( + itemCount: logic.msgList?.length ?? 0, + reverse: true, + padding: EdgeInsets.only(bottom: 12, top: 12), + itemBuilder: (context, index) { + return ChatItemCell(model: logic.msgList![index]); + }, + ), + ), + ), + Obx( + () => CommentFootView( + imgPath: logic.selectedImagePath.value, + isSending: logic.isSendingMsg.value, + onImgChange: (value) => logic.selectedImagePath.value = value, + onComment: (isSend) => logic.readySendMsg(isSend: isSend), + textCtr: logic.textEditController, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/hj_page/community/group_chat/detail/widget/chat_item_cell.dart b/lib/hj_page/community/group_chat/detail/widget/chat_item_cell.dart new file mode 100644 index 0000000..e322a87 --- /dev/null +++ b/lib/hj_page/community/group_chat/detail/widget/chat_item_cell.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../../../tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/widget/image_browser_page.dart'; +import '../../model/im_message_resp.dart'; + +class ChatItemCell extends StatefulWidget { + final IMMessageModel? model; + + ChatItemCell({super.key, this.model}); + + @override + State createState() { + return _ChatItemCellState(); + } +} + +class _ChatItemCellState extends State { + bool get isImg => widget.model?.image?.isNotEmpty == true; + + bool get isMe => globalStore.isMe(widget.model?.uid); + + double imageWidth = 173; + + @override + void initState() { + super.initState(); + } + + void _showImageScan(String imageUrl) { + if (imageUrl.isNotEmpty) { + ImageBrowserPage.open([widget.model?.image ?? '']); + } + } + + @override + Widget build(BuildContext context) { + if (isMe) { + return _buildRightStyle(); + } else { + return _buildLeftStyle(); + } + } + + Widget _buildLeftStyle() { + return Container( + padding: EdgeInsets.fromLTRB(0, 12, 0, 6), + child: Column( + children: [ + _buildTime(), + Container( + padding: EdgeInsets.fromLTRB(16, 0, 50, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () { + pushToPersonCenter(widget.model?.uid); + }, + child: SizedBox( + width: 40, + height: 40, + child: NetworkImageLoader( + imageUrl: widget.model?.portrait ?? "", + width: 40, + height: 40, + borderRadius: 20, + ), + ), + ), + SizedBox(width: 12), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.model?.name ?? "", + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 14, + ), + ), + SizedBox(height: 10), + if (widget.model?.content?.isNotEmpty == true) + Container( + padding: EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + margin: EdgeInsets.only(top: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + topRight: Radius.circular(12), + topLeft: Radius.circular(2)), + ), + child: Text( + widget.model?.content ?? "", + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ), + if (isImg) + InkWell( + enableFeedback: false, + onTap: () { + _showImageScan(widget.model?.image ?? ""); + }, + child: Container( + margin: EdgeInsets.only(top: 5), + alignment: Alignment.centerLeft, + constraints: BoxConstraints(maxWidth: 173), + child: _buildImageWidget(isLeft: true), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildRightStyle() { + return Container( + padding: EdgeInsets.fromLTRB(0, 12, 0, 6), + child: Column( + children: [ + _buildTime(), + Container( + padding: EdgeInsets.fromLTRB(50, 0, 16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + widget.model?.name ?? "", + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + SizedBox(height: 10), + if (widget.model?.content?.isNotEmpty == true) + Container( + padding: EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + margin: EdgeInsets.only(top: 5), + decoration: BoxDecoration( + color: Color(0xff1DC194), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + topLeft: Radius.circular(12), + topRight: Radius.circular(4)), + ), + child: Text( + widget.model?.content ?? "", + style: TextStyle( + color: Color(0xff141414), + fontSize: 14, + ), + ), + ), + if (isImg) + InkWell( + enableFeedback: false, + onTap: () { + _showImageScan(widget.model?.image ?? ""); + }, + child: Container( + margin: EdgeInsets.only(top: 5), + alignment: Alignment.centerRight, + constraints: BoxConstraints(maxWidth: 173), + child: _buildImageWidget(isLeft: false), + ), + ), + ], + ), + ), + SizedBox(width: 12), + GestureDetector( + onTap: () {}, + child: SizedBox( + width: 40, + height: 40, + child: NetworkImageLoader( + imageUrl: widget.model?.portrait, + width: 40, + height: 40, + borderRadius: 20, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildImageWidget({bool isLeft = false}) { + return Stack( + children: [ + NetworkImageLoader( + imageUrl: widget.model?.image ?? "", + width: imageWidth, + imgBorderRadius: BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + topRight: Radius.circular(isLeft ? 12 : 0), + topLeft: Radius.circular(isLeft ? 0 : 12), + ), + ), + if (isLeft) + Positioned( + bottom: 12, + left: 0, + right: 0, + child: Container( + alignment: Alignment.bottomCenter, + child: Container( + width: 72, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: AppColors.actionRed, + ), + child: Text( + '查看原图', + style: TextStyle(fontSize: 12, color: Colors.white), + ), + ), + ), + ), + ], + ); + } + + Widget _buildTime() { + // if (widget.model?.isShowTime == true) { + // return Container( + // margin: EdgeInsets.fromLTRB(0, 12, 0, 12), + // padding: EdgeInsets.symmetric(horizontal: 20, vertical: 2), + // // decoration: BoxDecoration( + // // borderRadius: BorderRadius.circular(30), + // // color: Color(0xfff7f7f7).withValues(alpha: 0.42), + // // ), + // child: Text( + // widget.model?.createAtDesc ?? "", + // style: TextStyle( + // color: Color(0xff666666), + // fontSize: 12, + // ), + // ), + // ); + // } + return SizedBox(); + } +} diff --git a/lib/hj_page/community/group_chat/group_chat_logic.dart b/lib/hj_page/community/group_chat/group_chat_logic.dart new file mode 100644 index 0000000..0f04bf8 --- /dev/null +++ b/lib/hj_page/community/group_chat/group_chat_logic.dart @@ -0,0 +1,46 @@ +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../hj_utils/api_service/group_service.dart'; +import 'model/im_group_list_model.dart'; + +class GroupChatLogic extends GetxController { + int pageNum = 1; + List? groupList; + RefreshController? refreshController; + + @override + void onReady() { + refreshData(); + super.onReady(); + } + + void refreshData() { + _loadData(); + } + + void loadMoreData() { + _loadData(page: pageNum + 1); + } + + void _loadData({int page = 1}) async { + try { + final retResp = await GroupService.getList(page); + pageNum = page; + groupList ??= []; + if (pageNum == 1) { + groupList?.clear(); + } + groupList?.addAll(retResp?.list ?? []); + retResp?.hasNext == true + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + } catch (e) { + debugLog(e); + refreshController?.loadComplete(); + } + refreshController?.refreshCompleted(); + update(); + } +} diff --git a/lib/hj_page/community/group_chat/group_chat_page.dart b/lib/hj_page/community/group_chat/group_chat_page.dart new file mode 100644 index 0000000..87c2ef4 --- /dev/null +++ b/lib/hj_page/community/group_chat/group_chat_page.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/main_page/provider/bottom_bar_provider.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import 'group_chat_logic.dart'; +import 'widget/chat_group_cell.dart'; + +class GroupChatPage extends StatelessWidget { + const GroupChatPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + global: false, + init: GroupChatLogic(), + builder: (logic) { + return pullYsRefresh( + onInit: (ctr) => logic.refreshController = ctr, + onRefresh: (ctr) => logic.refreshData(), + onLoading: (ctr) => logic.loadMoreData(), + child: CustomScrollView( + controller: + CommunityBottomProvider().scrollCtr(MediaStyle.GroupChat, 0), + slivers: [ + SliverToBoxAdapter( + child: AdsGridViewWidget( + 6, + accordingAdsType: true, + padding: EdgeInsets.fromLTRB(16, 12, 16, 18), + ), + ), + if (logic.groupList == null) + SliverToBoxAdapter(child: LoadingCenterWidget(height: 400)) + else if (logic.groupList!.isEmpty) + SliverToBoxAdapter( + child: SizedBox( + height: 400, + child: CErrorWidget( + retryOnTap: () => logic.refreshData(), + ), + ), + ) + else + SliverList.builder( + itemBuilder: (ctx, index) => + ChatGroupCell(logic.groupList![index]), + itemCount: logic.groupList!.length, + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/hj_page/community/group_chat/model/im_group_list_model.dart b/lib/hj_page/community/group_chat/model/im_group_list_model.dart new file mode 100644 index 0000000..97d4bb4 --- /dev/null +++ b/lib/hj_page/community/group_chat/model/im_group_list_model.dart @@ -0,0 +1,87 @@ +class IMGroupListModel { + bool? hasNext; + List? list; + int? total; + + IMGroupListModel({this.hasNext, this.list, this.total}); + + IMGroupListModel.fromJson(Map json) { + hasNext = json['hasNext']; + if (json['list'] != null) { + list = []; + json['list'].forEach((v) { + list!.add(new IMGroupItemModel.fromJson(v)); + }); + } + total = json['total']; + } + + Map toJson() { + final Map data = new Map(); + data['hasNext'] = this.hasNext; + if (this.list != null) { + data['list'] = this.list!.map((v) => v.toJson()).toList(); + } + data['total'] = this.total; + return data; + } +} + +class IMGroupItemModel { + String? cover; + String? createdAt; + + ///成员数量(假的) + int? fakeMemberNum; + int? groupId; + String? id; + + ///成员数量(真实) + int? memberNum; + String? name; + + ///加入群聊价格 0-免费 + int? price; + String? summary; + String? updatedAt; + + IMGroupItemModel( + {this.cover, + this.createdAt, + this.fakeMemberNum, + this.groupId, + this.id, + this.memberNum, + this.name, + this.price, + this.summary, + this.updatedAt}); + + IMGroupItemModel.fromJson(Map json) { + cover = json['cover']; + createdAt = json['createdAt']; + fakeMemberNum = json['fakeMemberNum']; + groupId = json['groupId']; + id = json['id']; + memberNum = json['memberNum']; + name = json['name']; + price = json['price']; + summary = json['summary']; + updatedAt = json['updatedAt']; + } + + Map toJson() { + final Map data = new Map(); + data['cover'] = this.cover; + data['createdAt'] = this.createdAt; + data['fakeMemberNum'] = this.fakeMemberNum; + data['groupId'] = this.groupId; + data['id'] = this.id; + data['memberNum'] = this.memberNum; + data['name'] = this.name; + data['price'] = this.price; + data['summary'] = this.summary; + data['updatedAt'] = this.updatedAt; + return data; + } +} diff --git a/lib/hj_page/community/group_chat/model/im_message_resp.dart b/lib/hj_page/community/group_chat/model/im_message_resp.dart new file mode 100644 index 0000000..1436dc9 --- /dev/null +++ b/lib/hj_page/community/group_chat/model/im_message_resp.dart @@ -0,0 +1,46 @@ +class IMMessageResp { + bool? hasNext; + List? list; + int? total; + + IMMessageResp({this.hasNext, this.list, this.total}); + + IMMessageResp.fromJson(Map json) { + hasNext = json['hasNext']; + if (json['list'] is List) { + list = []; + json['list'].forEach((v) { + list!.add(IMMessageModel.fromJson(v)); + }); + } + total = json['total']; + } + +} + +class IMMessageModel { + + String? id; + int? groupId; + int? uid; + String? name; + String? gender; + String? portrait; + String? content; + String? image; + String? createdAt; + + + IMMessageModel.fromJson(Map json) { + id = json['id']; + groupId = json['groupId']; + uid = json['uid']; + name = json['name']; + // gender = json['gender']; + portrait = json['portrait']; + content = json['content']; + image = json['image']; + createdAt = json['createdAt']; + } + +} \ No newline at end of file diff --git a/lib/hj_page/community/group_chat/widget/chat_group_cell.dart b/lib/hj_page/community/group_chat/widget/chat_group_cell.dart new file mode 100644 index 0000000..c19cb7d --- /dev/null +++ b/lib/hj_page/community/group_chat/widget/chat_group_cell.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_alert_widget.dart'; + +import '../../../../alert/mine/vip_level_dialog.dart'; +import '../../../../assets_tool/app_colors.dart'; +import '../../../../hj_utils/api_service/group_service.dart'; +import '../../../../tools_base/widget/header_widget.dart'; +import '../alert/group_buy_bottom_sheet.dart'; +import '../detail/soul_group_detail_page.dart'; +import '../model/im_group_list_model.dart'; +import 'package:hgdj/extension/extensions.dart'; + +class ChatGroupCell extends StatefulWidget { + final IMGroupItemModel model; + + ChatGroupCell(this.model, {super.key}); + + @override + State createState() { + return _ChatGroupCellState(); + } +} + +class _ChatGroupCellState extends State { + IMGroupItemModel get model => widget.model; + + void _addGroupEvent() async { + if (!globalStore.isVIPTopLevel) { + showVipLevelDialog('本内容需要开通至尊VIP会员\n\n开通会员 即可畅享高级群聊特权'); + return; + } + try { + LoadingAlertWidget.show(); + final retResp = await GroupService.getHasJoin(model.groupId); + LoadingAlertWidget.cancel(); + if (retResp.isSuccess) { + int status = retResp.data['status']; // 0-已被禁言 1-可发言 2-未加入 + if (status == 0) { + showToast("您已被禁言"); + } else if (status == 1) { + Get.to(GroupChatDetailPage(model)); + } else if (status == 2) { + var ret = await Get.bottomSheet( + GroupBuyBottomSheet(groupModel: widget.model)); + if (ret == true) { + Get.to(GroupChatDetailPage(model)); + } + } else { + showToast("群未知状态"); + } + } + } catch (e) { + LoadingAlertWidget.cancel(); + showToast("网络数据异常"); + debugLog(e); + } + } + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.only(left: 16, right: 16, bottom: 12), + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(6), + ), + child: InkWell( + enableFeedback: false, + onTap: _addGroupEvent, + child: Row( + children: [ + HeaderWidget( + headPath: model.cover ?? '', + level: 1, + headWidth: 64, + headHeight: 64, + isCircle: false, + radius: 32, + ), + 12.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + model.name ?? '', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500), + ), + 2.sizeBoxH, + Text( + model.summary ?? '', + maxLines: 1, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.55), + fontSize: 12, + ), + ), + 6.sizeBoxH, + Row( + children: [ + Text( + 'ID: ${model.groupId ?? 0} ${model.fakeMemberNum.countStr}人在玩', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.55), + fontSize: 12, + ), + ), + Spacer(), + GestureDetector( + onTap: _addGroupEvent, + child: Container( + width: 52, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed), + child: Text( + '加入群聊', + style: TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/community/post_detail_page/detail_widget/post_detail_cover_content.dart b/lib/hj_page/community/post_detail_page/detail_widget/post_detail_cover_content.dart new file mode 100644 index 0000000..9dd0442 --- /dev/null +++ b/lib/hj_page/community/post_detail_page/detail_widget/post_detail_cover_content.dart @@ -0,0 +1,250 @@ +import 'dart:ui'; + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/video/video_player_alert.dart'; +import 'package:hgdj/hj_utils/pay/pay_manager.dart'; +import 'package:hgdj/hj_utils/buy_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/hj_utils/widget_util.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:hgdj/tools_base/widget/image_browser_page.dart'; + +import '../../../../alert/video/vip_acg_dialog.dart'; +import '../../../../assets_tool/app_colors.dart'; +import '../../../mine/mine_share/mine_share_page.dart'; + +/// 帖子详情视频封面:未解锁时盖一层金币 / VIP 蒙层 +class PostDetailVideoContent extends StatefulWidget { + final VideoModel model; + + const PostDetailVideoContent({super.key, required this.model}); + + @override + State createState() => _PostDetailVideoContentState(); +} + +class _PostDetailVideoContentState extends State { + VideoModel get model => widget.model; + + @override + Widget build(BuildContext context) { + final isPlayable = model.isVideo() || model.isSeedPost(); + if (!isPlayable || model.sourceURL?.isNotEmpty != true) + return const SizedBox.shrink(); + + final isLocked = needPostCoin(model); + //非免费区的金币帖走金币解锁,其余走 VIP 解锁 + final isCoin = !(model.freeArea ?? false) && model.originCoins != 0; + + return Column( + children: [ + GestureDetector( + onTap: () { + if (needPostCoin(model)) return; + _openPlayer(); + }, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 220), + child: AspectRatio( + aspectRatio: 391 / 220, + child: Stack( + alignment: Alignment.center, + children: [ + NetworkImageLoader( + imageUrl: model.cover ?? '', + width: double.infinity, + height: double.infinity), + Image.asset('circle_play.webp'.videoPath, + width: 30, height: 30), + //未解锁蒙层 + if (isLocked) + Positioned.fill( + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), + child: Container( + color: Colors.black.withValues(alpha: .6), + child: isCoin ? _coinMask() : _vipMask(), + ), + ), + ), + ), + ], + ), + ), + ), + ), + if (model.isSeedPost()) 10.sizeBoxH, + ], + ); + } + + //金币解锁蒙层 + Widget _coinMask() { + final coins = model.originCoins ?? 0; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + EasyRichText( + '你只需花费 $coins 金币', + defaultStyle: textStyle(16, Colors.white, FontWeight.w400), + patternList: [ + EasyRichTextPattern( + targetString: '$coins', + style: textStyle(16, AppColors.actionRed, FontWeight.w400)), + ], + ), + 16.sizeBoxH, + const Text('即可解锁继续查看', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w400)), + 16.sizeBoxH, + _btn( + '立即解锁', + width: 84, + colors: const [Color(0xffFF6E6E), Color(0xffFF4D4D)], + onTap: () async { + //购买期间可能已被其他入口解锁,点的时候再判一次 + if (!needPostCoin(model)) { + _openPlayer(); + return; + } + if (!await _pay()) return; + model.vidStatus?.hasPaid = true; + if (mounted) setState(() {}); + _openPlayer(); + }, + ), + ], + ); + } + + //VIP 解锁蒙层 + Widget _vipMask() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('本视频需开通VIP免费观看', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w400)), + 16.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _btn( + '邀请得3日VIP', + width: 110, + colors: const [Color(0xffFFE8BE), Color(0xffE6B764)], + textColor: const Color(0xff694923), + onTap: () => Get.to(() => MineSharePage()), + ), + 18.sizeBoxW, + _btn( + '开通VIP免费看', + width: 115, + colors: const [Color(0xffFF6E6E), Color(0xffFF4D4D)], + onTap: () => Get.dialog(VipACGDialog()), + ), + ], + ), + ], + ); + } + + //蒙层上的胶囊按钮 + Widget _btn( + String text, { + required double width, + required List colors, + required VoidCallback onTap, + Color textColor = Colors.white, + }) { + return InkWell( + enableFeedback: false, + onTap: onTap, + child: Container( + width: width, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: LinearGradient(colors: colors), + borderRadius: BorderRadius.circular(15)), + child: Text(text, style: textStyle(12, textColor, FontWeight.w400)), + ), + ); + } + + Future _pay() async { + bool isOk = false; + await PayManager().buy( + model.id, + ProductType.media, + source: 'post_cover', + onSuccess: (data) { + globalStore.updateUserInfo(); + isOk = true; + }, + ); + return isOk; + } + + void _openPlayer() => Get.to(() => VideoPlayerAlert(model.realVideoUrl)); +} + +/// 帖子详情多图封面:竖向拼接,只有首尾切圆角 +class PostDetailCoverContent extends StatelessWidget { + final VideoModel model; + + const PostDetailCoverContent({super.key, required this.model}); + + @override + Widget build(BuildContext context) { + //有视频源时封面已在视频区展示,这里剔除避免重复 + if (model.sourceURL?.isNotEmpty == true) + model.seriesCover?.remove(model.cover); + final covers = model.seriesCover ?? []; + return Column( + children: [ + if (covers.isNotEmpty) + Wrap( + runSpacing: 2, + children: [ + for (final (index, url) in covers.indexed) + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => ImageBrowserPage.open(covers, index: index), + child: ClipRRect( + borderRadius: _radius(index, covers.length), + child: NetworkImageLoader( + imageUrl: url, + width: double.infinity, + fit: BoxFit.fill, + borderRadius: 0), + ), + ), + ], + ), + 18.sizeBoxH, + ], + ); + } + + BorderRadius _radius(int index, int total) { + const radius = Radius.circular(12); + if (total == 1) return const BorderRadius.all(radius); + if (index == 0) + return const BorderRadius.only(topLeft: radius, topRight: radius); + if (index == total - 1) + return const BorderRadius.only(bottomLeft: radius, bottomRight: radius); + return BorderRadius.zero; + } +} diff --git a/lib/hj_page/community/post_detail_page/detail_widget/post_detail_operation_view.dart b/lib/hj_page/community/post_detail_page/detail_widget/post_detail_operation_view.dart new file mode 100644 index 0000000..f9ed55d --- /dev/null +++ b/lib/hj_page/community/post_detail_page/detail_widget/post_detail_operation_view.dart @@ -0,0 +1,118 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../../alert/video/share_media_dialog.dart'; +import '../../../mine/collec_history_buy/col_his_buy_sub_page.dart'; +import 'package:hgdj/extension/extensions.dart'; + +/// 帖子详情操作栏:浏览量 / 点赞 / 评论 / 分享 +class PostDetailOperationView extends StatefulWidget { + final VideoModel model; + + const PostDetailOperationView({super.key, required this.model}); + + @override + State createState() => + _PostDetailOperationViewState(); +} + +class _PostDetailOperationViewState extends State { + VideoModel get model => widget.model; + + @override + Widget build(BuildContext context) { + return Container( + height: 24, + margin: EdgeInsets.only(bottom: 18.h), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 浏览量 + _iconLabel( + icon: "eye_grey.webp".commonImgPath, + gap: 2, + text: model.pageViewCount?.countStr ?? "", + fontSize: 12.sp, + ), + 40.sizeBoxW, + // 点赞 + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _toggleLike, + child: _iconLabel( + icon: model.vidStatus?.hasLiked == true + ? "like_red.png".commonImgPath + : "like_empty_gray.png".communityPath, + gap: 5.w, + text: model.likeCount?.countOr("喜欢") ?? '', + fontSize: 12.sp, + ), + ), + 40.sizeBoxW, + // 评论量 + _iconLabel( + icon: "count_msg_gray.png".communityPath, + gap: 2, + text: model.commentCount?.countStr ?? "", + fontSize: 12.sp, + color: const Color(0xff999999), + ), + 40.sizeBoxW, + // 分享 + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Get.dialog(ShareMediaDialog(videoModel: model)), + child: _iconLabel( + icon: 'share_grey.png'.commonImgPath, + iconSize: 16, + gap: 4.w, + text: '分享', + fontSize: 10.sp, + ), + ), + ], + ), + ); + } + + /// 图标 + 文案的横向组合 + Widget _iconLabel({ + required String icon, + required String text, + required double fontSize, + double iconSize = 20, + double gap = 2, + Color color = const Color(0xff989898), + }) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset(icon, width: iconSize, height: iconSize), + gap.sizeBoxW, + Text(text, style: TextStyle(color: color, fontSize: fontSize)), + ], + ); + } + + /// 点赞 / 取消点赞 + void _toggleLike() async { + final wasLiked = model.vidStatus?.hasLiked ?? false; + final count = model.likeCount ?? 0; + final res = wasLiked + ? await CommonService.cancelLike( + model.id ?? '', LoadDataType.post.apiType) + : await CommonService.sendLike( + model.id ?? '', LoadDataType.post.apiType); + if (!res || !mounted) return; + model.vidStatus?.hasLiked = !wasLiked; + model.likeCount = max(0, count + (wasLiked ? -1 : 1)); + setState(() {}); + } +} diff --git a/lib/hj_page/community/post_detail_page/detail_widget/post_detail_unlock_dialog.dart b/lib/hj_page/community/post_detail_page/detail_widget/post_detail_unlock_dialog.dart new file mode 100644 index 0000000..6c3e741 --- /dev/null +++ b/lib/hj_page/community/post_detail_page/detail_widget/post_detail_unlock_dialog.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/pay/pay_manager.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +/// 帖子金币解锁弹窗:解锁成功回传 true,点空白关闭交给 barrier +class PostDetailUnlockDialog extends StatelessWidget { + final VideoModel model; + + const PostDetailUnlockDialog({super.key, required this.model}); + + @override + Widget build(BuildContext context) { + return Center( + child: Material( + color: Colors.transparent, + child: InkWell( + enableFeedback: false, + onTap: () => PayManager().buy( + model.id, + ProductType.media, + source: 'post_unlock', + onSuccess: (data) { + globalStore.updateUserInfo(); + Get.back(result: true); + }, + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(30)), + gradient: LinearGradient( + colors: [ + Color.fromRGBO(255, 238, 220, 1), + Color.fromRGBO(249, 219, 191, 1) + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('post_detail_coin.png'.communityPath, + width: 30, height: 30), + Text( + '${model.videoCoin()}金币解锁继续', + style: const TextStyle( + color: Color(0xff370000), + fontSize: 14, + fontWeight: FontWeight.w400), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/community/post_detail_page/detail_widget/post_detail_word_content.dart b/lib/hj_page/community/post_detail_page/detail_widget/post_detail_word_content.dart new file mode 100644 index 0000000..7c88313 --- /dev/null +++ b/lib/hj_page/community/post_detail_page/detail_widget/post_detail_word_content.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/image_browser_page.dart'; + +import '../../../../tools_base/richTextParsing/html_parser.dart'; +import '../../../../tools_base/widget/shrink_wrap.dart'; +import '../../community_tag_page/community_tag_page.dart'; + +/// 黄游帖的游戏介绍 +class GameDetailWordContent extends StatelessWidget { + final VideoModel model; + + const GameDetailWordContent({super.key, required this.model}); + + @override + Widget build(BuildContext context) { + if (model.newsType != 'ADULT_GAME') return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('游戏介绍', style: TextStyle(color: Colors.white, fontSize: 16)), + 18.sizeBoxH, + Text(model.content ?? '', + style: const TextStyle(color: Colors.white, fontSize: 16)), + ], + ); + } +} + +/// 帖子正文:标题 + 标签 + 富文本 / 纯文本内容 +class PostDetailWordContent extends StatelessWidget { + final VideoModel model; + + const PostDetailWordContent({super.key, required this.model}); + + @override + Widget build(BuildContext context) { + if (model.newsType == 'ADULT_GAME') return const SizedBox.shrink(); + final contentStyle = + TextStyle(color: const Color(0xFFDCDCDC), fontSize: 14.sp); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 14.sizeBoxH, + //标题 + if (model.title?.isNotEmpty == true) + Padding( + padding: EdgeInsets.only(bottom: 1.h), + child: Text(model.title ?? '', style: contentStyle)), + //标签 + _tags(), + 12.h.sizeBoxH, + Divider(height: .5, color: Colors.white.withValues(alpha: .05)), + 18.h.sizeBoxH, + //正文:富文本优先,没有富文本才走纯文本 + if (model.richText?.isNotEmpty == true) + _richText() + else if (model.content?.isNotEmpty == true) + Padding( + padding: EdgeInsets.only(bottom: 18.h), + child: Text(model.content ?? '', style: contentStyle)), + ], + ); + } + + Widget _richText() { + return DefaultTextStyle( + style: const TextStyle(color: Colors.white), + child: Wrap( + alignment: WrapAlignment.start, + children: HtmlParser( + width: double.infinity, + onLinkTap: pushToPageByLink, + imgLinkTap: (url) => ImageBrowserPage.open([url]), + ).parse(model.richText ?? ''), + ), + ); + } + + Widget _tags() { + final tags = model.tags ?? []; + if (tags.isEmpty) return const SizedBox(); + return Padding( + padding: const EdgeInsets.only(top: 10), + child: ShrinkWrap( + spacing: 0, + runSpacing: 6, + maxLines: 1, + children: tags + .map((e) => InkWell( + enableFeedback: false, + onTap: () => Get.to(() => CommunityTagDetailPage(model: e)), + child: Container( + padding: const EdgeInsets.fromLTRB(8, 3, 8, 3), + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: const Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(3)), + child: Text('#${e.name}', + style: const TextStyle( + color: Color(0x8CFFFFFF), fontSize: 12)), + ), + )) + .toList(), + ), + ); + } +} diff --git a/lib/hj_page/community/post_detail_page/detail_widget/post_resource_info_widget.dart b/lib/hj_page/community/post_detail_page/detail_widget/post_resource_info_widget.dart new file mode 100644 index 0000000..045e7f1 --- /dev/null +++ b/lib/hj_page/community/post_detail_page/detail_widget/post_resource_info_widget.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +import '../../../../hj_model/video_model.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'post_detail_unlock_dialog.dart'; + +/// 种子帖 / 黄游帖的资源信息与下载入口 +class PostResourceInfoWidget extends StatefulWidget { + final VideoModel videoModel; + + const PostResourceInfoWidget({super.key, required this.videoModel}); + + @override + State createState() => _PostResourceInfoWidgetState(); +} + +class _PostResourceInfoWidgetState extends State { + VideoModel get model => widget.videoModel; + + /// 是否还需要花金币解锁 + bool get _needLock { + if (model.freeArea == true) return false; + final me = globalStore.meInfo; + //金币视频限免期内:全场免费 或 50 金币以内免费 + final isFreeTime = DateTimeUtil.calTime3(me?.goldVideoFreeExpire) > 0; + if (isFreeTime && (me?.allGoldVideoFree == true || (model.coins ?? 0) < 50)) + return false; + return model.isCoinVideo() && + !globalStore.isMe(model.publisher?.uid) && + !(model.vidStatus?.hasPaid ?? false); + } + + String get _buyDesc => + _needLock ? '${model.realCoins ?? 0}金币解锁 即可下载种子' : '开通VIP 即可下载种子'; + + @override + Widget build(BuildContext context) { + final isSeed = model.newsType == 'SEED_LINK'; + if (!isSeed && model.newsType != 'ADULT_GAME') return const SizedBox(); + + const descStyle = + TextStyle(color: Color(0xff999999), fontSize: 14, height: 1.5); + //未解锁、或非 VIP 的非免费区内容,都只能看解锁入口 + final isLocked = + _needLock || (!globalStore.isVIP && model.freeArea != true); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + //种子帖的影片信息与下载说明 + if (isSeed) ...[ + if ((model.seedSize ?? 0) > 0) + Text('[影片大小]:${model.seedSizeDesc}', style: descStyle), + if ((model.seedPlayTime ?? 0) > 0) + Text('[影片时长]:${model.seedPlayTimeDesc}', style: descStyle), + const Text( + '[下载说明]:解锁后请复制种子的下载链接到手机自带浏览器(谷歌/UC/夸克/火狐 等)打开!或使用BT软件(迅雷/Vuze/uTorrent/BitTorrent 等)进行下载.', + style: descStyle, + ), + const Text('[温馨提示]:下载后请勿传播分享,仅供个人欣赏哟~~', style: descStyle), + 18.sizeBoxH, + ], + //解锁入口 / 复制链接 + Center( + child: isLocked + ? InkWell( + enableFeedback: false, + onTap: _showUnlock, + child: Text( + _buyDesc, + style: const TextStyle( + fontSize: 16, + color: AppColors.primaryHighColor, + fontWeight: FontWeight.w500), + ), + ) + : InkWell( + enableFeedback: false, + onTap: _copyLink, + child: Image.asset('link_copy_icon.webp'.communityPath, + width: 180, height: 32), + ), + ), + ], + ); + } + + /// 需要金币的弹解锁弹窗,否则去充值页开 VIP + void _showUnlock() async { + if (!_needLock) { + pushToWalletPage(tabPosition: 0); + return; + } + final result = await Get.dialog( + PostDetailUnlockDialog(model: model), + barrierColor: const Color.fromRGBO(14, 20, 30, 0.6), + ); + if (result == null || !mounted) return; + model.vidStatus?.hasPaid = true; + setState(() {}); + } + + void _copyLink() { + Clipboard.setData(ClipboardData(text: model.seedRealLink)); + showToast('链接地址复制成功'); + } +} diff --git a/lib/hj_page/community/post_detail_page/post_detail_logic.dart b/lib/hj_page/community/post_detail_page/post_detail_logic.dart new file mode 100644 index 0000000..c5b587c --- /dev/null +++ b/lib/hj_page/community/post_detail_page/post_detail_logic.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/comment/comment_mixin.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; + +import '../../../hj_utils/history_util.dart'; +import 'post_detail_page.dart'; + +class PostDetailLogic extends GetxController with CommentMixin { + // ===== 状态 ===== + bool isLoading = false; + + // ===== 数据 ===== + final PostDetailPageArgument args; + VideoModel? model; + + // ===== Controller ===== + final scrollCtr = ScrollController(); + + PostDetailLogic(this.args); + + @override + void onInit() { + super.onInit(); + id = args.id; + objType = 'video'; + } + + @override + void onReady() { + super.onReady(); + initData(); + } + + @override + void onClose() { + // scrollCtr 在本类创建,需自行释放;commentController 由 CommentMixin.onClose 处理 + scrollCtr.dispose(); + super.onClose(); + } + + //详情与评论并行拉,两个都回来才收 loading + void initData() { + isLoading = true; + update(); + Future.wait([fetchDetail(), fetchComments(args.id ?? '')]).then((_) { + isLoading = false; + update(); + }); + } + + Future fetchDetail() async { + final res = await VidService.getDetail(args.id ?? ''); + if (res != null) { + model = res; + HistoryUtil.insert(model!, MediaStyle.Community); + } + update(); + } + + /// 上下篇切换:原地换 id 重拉,不 push 新页 + /// 配套数据是 args.videoModels(各入口都在传),但翻页入口还没接,别当死代码删 + void nextOrPrePost(String? newId) { + args.id = newId; + id = newId; + page = 1; + comments.clear(); + initData(); + } +} diff --git a/lib/hj_page/community/post_detail_page/post_detail_page.dart b/lib/hj_page/community/post_detail_page/post_detail_page.dart new file mode 100644 index 0000000..c1ecaa3 --- /dev/null +++ b/lib/hj_page/community/post_detail_page/post_detail_page.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/comment/cell/comment_item.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +import '../../comment/widget/comment_foot_view.dart'; +import '../../home/widget/quick_search_view.dart'; +import '../widget/post_user_info_view.dart'; +import 'detail_widget/post_detail_cover_content.dart'; +import 'detail_widget/post_detail_operation_view.dart'; +import 'detail_widget/post_detail_word_content.dart'; +import 'detail_widget/post_resource_info_widget.dart'; +import 'post_detail_logic.dart'; + +/// 过滤掉广告位,只留真实帖子 +List? _filters(List? videoModel) { + return (videoModel ?? []) + .where((e) => !e.isRandomAd() && !e.isAdsArr()) + .toList(); +} + +class PostDetailPageArgument { + final List? videoModels; + String? id; + + PostDetailPageArgument({List? models, this.id}) + : videoModels = _filters(models); +} + +class PostDetailPage extends StatefulWidget { + final PostDetailPageArgument argument; + + const PostDetailPage({super.key, required this.argument}); + + @override + State createState() => _PostDetailPageState(); +} + +// 详情页能点头像进用户主页,主页作品列表又能开帖子详情(A→B→A),两份会并存, +// 所以 logic 必须按实例隔离;不能省掉 tag 让它们共用一个 +class _PostDetailPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: PostDetailLogic(widget.argument), + tag: uniqueTag, + builder: (logic) { + final model = logic.model; + //置顶评论下标:getter 内部要遍历评论,别丢进 itemBuilder 里每条算一遍 + final topIndex = logic.topPinnedCommentIndex; + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + titleSpacing: 18.w, + automaticallyImplyLeading: false, + title: _titleView(logic)), + body: logic.isLoading || model == null + ? SizedBox(height: 300, child: LoadingCenterWidget()) + : Column( + children: [ + Expanded( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 18.w), + child: pullYsRefresh( + enablePullDown: false, + onInit: (refreshCtr) => logic.refreshCtr = refreshCtr, + onLoading: (_) => + logic.fetchComments(logic.args.id ?? ''), + child: CustomScrollView( + controller: logic.scrollCtr, + slivers: [ + SliverToBoxAdapter(child: 14.h.sizeBoxH), + //发帖人信息 + SliverToBoxAdapter( + child: PostUserInfoView( + model: model, isDetailStyle: true)), + //正文 + SliverToBoxAdapter( + child: PostDetailWordContent(model: model)), + //富文本帖自带图,不再单独铺封面 + if (model.richText?.isNotEmpty != true) + SliverToBoxAdapter( + child: + PostDetailCoverContent(model: model)), + SliverToBoxAdapter( + child: GameDetailWordContent(model: model)), + SliverToBoxAdapter( + child: PostDetailVideoContent(model: model)), + SliverToBoxAdapter( + child: PostResourceInfoWidget( + videoModel: model)), + SliverToBoxAdapter(child: 16.sizeBoxH), + //点赞/收藏/分享 + SliverToBoxAdapter( + child: PostDetailOperationView(model: model)), + SliverToBoxAdapter( + child: AdsGridViewWidget( + 7, + accordingAdsType: true, + ), + ), + //评论数 + 快捷搜索词 + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${model.commentCount?.countStr}条评论', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Colors.white + .withValues(alpha: 0.9)), + ), + if (logic.quickSearchList.isNotEmpty) ...[ + 18.sizeBoxH, + QuickSearchView(logic.quickSearchList) + ] + ], + ).paddingOnly(bottom: 14), + ), + //评论列表 + if (logic.comments.isEmpty) + SliverToBoxAdapter( + child: SizedBox( + height: 220, child: CErrorWidget())) + else + SliverList.builder( + itemCount: logic.comments.length, + itemBuilder: (_, index) => CommentItem( + logic.comments[index], + objId: model.id ?? '', + //置顶那条上方插一组 Banner + topBanners: index == topIndex && + logic.commentTopBanners.isNotEmpty + ? logic.commentTopBanners + : null, + replyHandler: (comment, {reply}) => + logic.readyReplyComment(comment, + replyModel: reply), + ), + ) + ], + ), + ), + ), + ), + //底部评论输入条 + Obx( + () => CommentFootView( + video: model, + showCollect: true, + onComment: (isSend) => + logic.readyReplyComment(null, isSend: isSend), + isSending: logic.isSendingMsg.value, + textCtr: logic.commentController, + imgPath: logic.selectedImagePath.value, + onImgChange: (value) => + logic.selectedImagePath.value = value, + ), + ) + ], + ), + ); + }, + ); + } + + //标题栏:未滚动 / 游戏帖显示固定标题,滚过 60 后换成发帖人信息 + Widget _titleView(PostDetailLogic logic) { + final scrollCtr = logic.scrollCtr; + return ListenableBuilder( + listenable: scrollCtr, + builder: (_, __) { + final isGame = logic.model?.newsType == 'ADULT_GAME'; + if (!scrollCtr.hasClients || scrollCtr.offset < 60 || isGame) { + return Stack( + alignment: Alignment.centerLeft, + children: [ + _backBtn(), + Center( + child: Text(switch (logic.model?.newsType) { + 'SEED_LINK' => '种子详情', + 'ADULT_GAME' => '游戏详情', + _ => '帖子详情', + }), + ), + ], + ); + } + return Row( + children: [ + _backBtn(), + 4.sizeBoxW, + Expanded( + child: PostUserInfoView( + model: logic.model!, isDetailStyle: true, avatarSize: 30)), + ], + ); + }, + ); + } + + Widget _backBtn() { + return GestureDetector( + onTap: () => Get.back(), + child: const Icon(Icons.arrow_back_ios, size: 18, color: Colors.white), + ); + } +} diff --git a/lib/hj_page/community/publish_page/chose_tag_logic.dart b/lib/hj_page/community/publish_page/chose_tag_logic.dart new file mode 100644 index 0000000..faf43c7 --- /dev/null +++ b/lib/hj_page/community/publish_page/chose_tag_logic.dart @@ -0,0 +1,55 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/search_service.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import 'chose_tag_page.dart'; + +class ChoseTopicLogic extends GetxController { + ChoseTopicLogic({ + required this.entry, + List initialSelectTags = const [], + }) : selectTags = initialSelectTags.obs; + + // ========== 属性声明 ========== + final ChoseTopicEntry entry; + final RxList selectTags; + final dataSource = []; + RefreshController? refreshCtr; + bool isLoading = true; + + // ========== 生命周期 ========== + @override + void onReady() { + super.onReady(); + loadTags(); + } + + // ========== 公开方法 ========== + /// 标签一次拉全量,接口本身没有分页 + Future loadTags() async { + try { + dataSource.addAll(await SearchService.fetchPublishTags()); + } catch (e) { + //失败也要收 loading,否则页面一直转圈 + debugLog('fetchPublishTags $e'); + } + refreshCtr?.loadNoData(); + isLoading = false; + update(); + } + + /// 单选并返回 + void pickSingleAndBack(TagsBean model) { + //点的就是已选中那项:不必重复赋值,直接关页面(原来 return 掉会点了没反应) + if (selectTags.any((e) => e.id == model.id)) { + Get.back(); + return; + } + selectTags + ..clear() + ..add(model); + Get.back(result: model); + } +} diff --git a/lib/hj_page/community/publish_page/chose_tag_page.dart b/lib/hj_page/community/publish_page/chose_tag_page.dart new file mode 100644 index 0000000..ef2e564 --- /dev/null +++ b/lib/hj_page/community/publish_page/chose_tag_page.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/community/publish_page/chose_tag_logic.dart'; +import 'package:hgdj/hj_page/home/search_page/widget/topic_item.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +enum ChoseTopicEntry { + home('选择标签'), + squire('选择话题'); + + final String title; + + const ChoseTopicEntry(this.title); +} + +class ChoseTopicPage extends StatelessWidget { + final ChoseTopicEntry? entry; + final List? selectTags; + + const ChoseTopicPage({super.key, this.entry, this.selectTags}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: ChoseTopicLogic( + entry: entry ?? ChoseTopicEntry.home, + initialSelectTags: selectTags ?? const [], + ), + global: false, + builder: (logic) => Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text(logic.entry.title), + ), + body: pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + //标签一次拉全量,没有下拉刷新;不关掉的话下拉会卡在转圈(onRefresh 为空没人收) + enablePullDown: false, + child: _buildBody(logic), + ), + ), + ); + } + + Widget _buildBody(ChoseTopicLogic logic) { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.dataSource.isEmpty) + return CErrorWidget(retryOnTap: logic.loadTags); + return _buildList(logic); + } + + /// 单选 list + Widget _buildList(ChoseTopicLogic logic) { + return ListView.separated( + padding: EdgeInsets.only(left: 18.w, right: 18.w, top: 14.h), + separatorBuilder: (_, __) => 12.h.sizeBoxH, + itemCount: logic.dataSource.length, + itemBuilder: (_, index) { + final model = logic.dataSource[index]; + final selected = + logic.selectTags.indexWhere((e) => e.id == model.id) != -1; + return TopicItem( + model: model, + isSelect: selected, + onTap: () => logic.pickSingleAndBack(model), + ); + }, + ); + } +} diff --git a/lib/hj_page/community/publish_page/publish_logic.dart b/lib/hj_page/community/publish_page/publish_logic.dart new file mode 100644 index 0000000..cff5781 --- /dev/null +++ b/lib/hj_page/community/publish_page/publish_logic.dart @@ -0,0 +1,384 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:image_pickers/image_pickers.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/hj_utils/file_util.dart'; +import 'package:hgdj/hj_utils/media_info.dart'; +import 'package:hgdj/tools_base/file_upload/file_upload_tool.dart'; +import 'package:hgdj/tools_base/loading/loading_alert_widget.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; + +import 'publish_page.dart'; + +/// 社区发帖的视频载体:单条视频的本地/远程地址 + 元信息 +class PublishVideoModel { + String? localPath; + String? localCover; + String? remotePath; + String? remoteCover; + String? customCover; + double? ratio; + String? name; + String? md5; + int? playTime; + int? size; + String? sourceid; + String? resolution; + + PublishVideoModel( + {this.localPath, + this.localCover, + this.remotePath, + this.remoteCover, + this.ratio, + this.name, + this.playTime, + this.size, + this.sourceid, + this.resolution, + this.md5, + this.customCover}); + + copy() { + return PublishVideoModel( + localCover: localCover, + localPath: localPath, + remotePath: remotePath, + remoteCover: remoteCover, + resolution: resolution, + ratio: ratio, + name: name, + playTime: playTime, + size: size, + sourceid: sourceid, + md5: md5, + customCover: customCover); + } +} + +class PublishLogic extends GetxController { + // Rx topic = Rx(null); + RxList topics = [].obs; + + Rx gold = Rx(null); + + RxList imgs = [''].obs; + + final describeController = TextEditingController(); + + final titleController = TextEditingController(); + final titleFouceN = FocusNode(); + final contentFouceN = FocusNode(); + final goldFouceN = FocusNode(); + + // 链接的视频 + Rx video = Rx(PublishVideoModel()); + + final PublishType type; + + PublishLogic(this.type); + + @override + void onClose() { + describeController.dispose(); + titleController.dispose(); + titleFouceN.dispose(); + contentFouceN.dispose(); + goldFouceN.dispose(); + super.onClose(); + } + + void unfocus() { + if (titleFouceN.context != null && titleFouceN.hasFocus) { + titleFouceN.unfocus(); + } + if (contentFouceN.context != null && contentFouceN.hasFocus) { + contentFouceN.unfocus(); + } + if (goldFouceN.context != null && goldFouceN.hasFocus) { + goldFouceN.unfocus(); + } + } + + /// 视频封面相关 + choseCover() async { + final listMedia = await ImagePickers.pickerPaths( + uiConfig: UIConfig(uiThemeColor: Colors.white), + galleryMode: GalleryMode.image, + selectCount: 1, + showCamera: false, + ); + if (listMedia.isEmpty) return; + video.value = video.value.copy()..customCover = listMedia.first.path ?? ''; + } + + deleteCover() { + video.value = video.value.copy()..customCover = null; + } + + ///视频相关 + choseVideo() async { + var pickedFile = await ImagePicker().pickVideo(source: ImageSource.gallery); + if (pickedFile == null) return null; + + if (!File(pickedFile.path).existsSync()) { + showToast("用户视频文件损坏或格式错误"); + return; + } + var path = pickedFile.path; + + final mediaInfo = await _checkVideoRule(path); + if (mediaInfo == null) return; + final coverPath_ = await VideoThumbnail.thumbnailFile( + video: path, + thumbnailPath: (await getTemporaryDirectory()).path, + imageFormat: ImageFormat.PNG, + quality: 100, + ); + + final videoM_ = PublishVideoModel( + localCover: coverPath_, + localPath: path, + ratio: mediaInfo.ratio ?? 1, + name: "${DateTime.now().toIso8601String()}.mp4", + playTime: mediaInfo.playTime, + size: mediaInfo.size, + resolution: mediaInfo.resolution); + + video.value = videoM_..customCover = video.value.customCover; + } + + deleteSelectVideo() { + video.value = PublishVideoModel()..customCover = video.value.customCover; + } + + /// 图集相关 + choseImg() async { + final listMedia = await ImagePickers.pickerPaths( + uiConfig: UIConfig(uiThemeColor: Colors.white), + galleryMode: GalleryMode.image, + selectCount: 9 - (imgs.length - 1), + showCamera: false, + ); + if (listMedia.isEmpty) return; + final ret = listMedia.map((e) => e.path ?? '').toList(); + imgs.insertAll(0, ret); + } + + publish() async { + final res = _checkEnable(); + if (!res) return; + FocusManager.instance.primaryFocus?.unfocus(); + //上传图片前 先移除第一个占位 + List imgs_ = []; + if (type != PublishType.squireVideo) { + imgs_.addAll(imgs); + imgs_.remove(''); + } + + // 上传图片(封面),成功后再走视频上传 + 提交 + FileUploadTool().uploadImagesWithProgress( + imgs_, + onFailure: () => showToast("图片上传失败"), + onSuccess: (seriesCover) => _publishWithCover(seriesCover), + ); + } + + /// 封面上传完成后:上传视频(可选)→ 提交发布 + Future _publishWithCover(List seriesCover) async { + // 无视频:直接提交 + if (video.value.localPath == null) { + _submitPost(seriesCover, null); + return; + } + // 有视频:取元信息 → 校验封面 → 上传视频 → 提交 + // 取元信息期间也要有遮挡,否则这段空窗期发布按钮可重复点 + LoadingAlertWidget.show(title: "正在处理视频..."); + final mediaInfo = await getMediaInfo(video.value.localPath ?? ""); + LoadingAlertWidget.cancel(); + video.value.remoteCover = seriesCover.first; + if (video.value.remoteCover == null) { + showToast("视频封面上传失败"); + return; + } + FileUploadTool().uploadVideoWithProgress( + video.value.localPath ?? '', + onFailure: () => showToast("视频上传失败"), + onSuccess: (fileR) { + video.value.remotePath = fileR.videoUri; + video.value.md5 = fileR.md5; + video.value.sourceid = fileR.id; + _submitPost(seriesCover, mediaInfo); + }, + ); + } + + /// 提交发布([mediaInfo] 仅视频帖有) + Future _submitPost( + List seriesCover, MediaInfo? mediaInfo) async { + final result = await VidService.submit( + title: titleController.text, + newsType: _generateNewsType(), + tags: topics.map((element) => element.id ?? '').toList(), + seriesCover: seriesCover, + coins: int.parse(gold.value ?? '0'), + playTime: mediaInfo?.playTime, + mimeType: mediaInfo != null ? "mp4" : null, + content: describeController.text, + sourceID: video.value.sourceid, + md5: video.value.md5, + ratio: video.value.ratio, + size: video.value.size, + resolution: video.value.resolution, + sourceURL: video.value.remotePath, + cover: video.value.remoteCover, + ); + + if (result) { + await CommonAlert.show( + title: "发布成功", + content: '帖子发布后将在24小时内进行审核,您可在【我的】-【我的帖子】中进行查看', + showCancel: false, + ); + Get.back(); + } else { + showToast('发布失败'); + } + } + + /// 检查本地视频 + Future _checkVideoRule(String path) async { + if (path.isEmpty) { + showToast("视频地址错误"); + return null; + } + + MediaInfo? videoInfo = await getMediaInfo(path); + if (videoInfo == null) { + showToast('用戶視頻文件損壞或格式錯誤'); + return null; + } + + //时间 + if ((videoInfo.playTime ?? 0) < 30 && (videoInfo.playTime ?? 0) > 0) { + showToast('請選擇30秒以上視頻'); + return null; + } + + //大小 + int sizeM = (videoInfo.size ?? 0) ~/ (MB_SIZE); + if (sizeM > 300 || sizeM == 0) { + showToast('請選擇250M內視頻'); + return null; + } + + //分辨率 + String resolution = videoInfo.resolution ?? ''; + if (resolution.isEmpty || !resolution.contains("*")) { + showToast('用戶視頻文件損壞或格式錯誤'); + return null; + } + + List list = resolution.split("*"); + var curResolution = double.parse(list[0]) * double.parse(list[1]); + if (curResolution < 360 * 360) { + showToast('請選擇分辨率大於360*360以上視頻'); + return null; + } + return videoInfo; + } + + bool _checkEnable() { + if (type == PublishType.homeImgText || type == PublishType.homeImg) { + if (titleController.text.isEmpty) { + showToast('请输入标题!'); + return false; + } + + if (describeController.text.isEmpty && type == PublishType.homeImgText) { + showToast('请输入描述!'); + return false; + } + if (imgs.length <= 1) { + showToast('请选择图片!'); + return false; + } + if (topics.isEmpty) { + showToast('请选择话题哦~'); + return false; + } + return true; + } + if (type == PublishType.homeVideo) { + if (titleController.text.isEmpty) { + showToast('请输入标题!'); + return false; + } + if (describeController.text.isEmpty) { + showToast('请输入描述!'); + return false; + } + if (video.value.localPath == null) { + showToast('请选择需要上传的视频!'); + return false; + } + if (imgs.length <= 1) { + showToast('请选择图片!'); + return false; + } + + if (topics.isEmpty) { + showToast('请选择话题哦~'); + return false; + } + return true; + } + if (type == PublishType.squireImgText) { + if (topics.isEmpty) { + showToast('至少选择一个话题哦~'); + return false; + } + if (describeController.text.isEmpty) { + showToast('请输入描述!'); + return false; + } + + if (imgs.length == 1) { + showToast('请选择图片!'); + return false; + } + return true; + } + + if (topics.isEmpty) { + showToast('至少选择一个话题哦~'); + return false; + } + if (describeController.text.isEmpty) { + showToast('请输入描述!'); + return false; + } + if (video.value.localPath == null) { + showToast('请选择需要上传的视频!'); + return false; + } + + return true; + } + + String _generateNewsType() { + if (type == PublishType.homeImg || + type == PublishType.homeImgText || + type == PublishType.squireImgText) return 'COVER'; + final duration = video.value.playTime ?? 0; + if (duration <= 300) return 'SP'; + return 'SP'; + } +} diff --git a/lib/hj_page/community/publish_page/publish_page.dart b/lib/hj_page/community/publish_page/publish_page.dart new file mode 100644 index 0000000..d594dcb --- /dev/null +++ b/lib/hj_page/community/publish_page/publish_page.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_page/community/publish_page/chose_tag_page.dart'; +import 'package:hgdj/hj_page/community/publish_page/publish_logic.dart'; +import 'package:hgdj/hj_page/community/publish_page/publish_rule_page.dart'; +import 'package:hgdj/hj_page/community/publish_page/widget/publish_widget.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; + +enum PublishType { + homeImg(RuleType.home, ChoseTopicEntry.home, '发布图片'), + homeImgText(RuleType.home, ChoseTopicEntry.home, '发布图文'), + homeVideo(RuleType.home, ChoseTopicEntry.home, '发布视频'), + squireImgText(RuleType.squire, ChoseTopicEntry.squire, '发布帖子'), + squireVideo(RuleType.squire, ChoseTopicEntry.squire, '发布帖子'); + + final RuleType rule; + final ChoseTopicEntry tag; + final String title; + + const PublishType(this.rule, this.tag, this.title); +} + +extension PublishMore on PublishType { + /// 各类型的表单项,顺序即页面从上到下的排列 + List get children => switch (this) { + PublishType.homeImg => [ + TopicWidget(), + InputTitleWidget(), + ChoseImageWidget(highlightText: '添加图集'), + ], + PublishType.homeImgText => [ + TopicWidget(), + InputTitleWidget(), + InputDescribeWidget(), + ChoseImageWidget(highlightText: '添加图集'), + ], + PublishType.homeVideo => [ + TopicWidget(), + InputTitleWidget(), + InputDescribeWidget(), + ChoseVideoWidget(), + ChoseImageWidget( + hint: '「默认第一张为封面」图片数量最多9张哦~', + highlightText: '「默认第一张为封面」', + highlightTextSize: 12.sp, + highlightTextColor: const Color(0xffB40400), + ), + SettingGoldWidget(), + ], + PublishType.squireImgText => [ + TopicWidget(), + InputDescribeWidget(), + ChoseImageWidget(hint: '第一张图集默认为封面'), + ], + PublishType.squireVideo => [ + TopicWidget(), + InputDescribeWidget(), + ChoseVideoWidget(), + ], + }; +} + +class PublishPage extends StatelessWidget { + final PublishType type; + + const PublishPage({super.key, this.type = PublishType.homeImgText}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: PublishLogic(type), + builder: (logic) => Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text(logic.type.title), + actions: [ + TextButton( + onPressed: () => CommonAlert.show( + title: '发布规则', + content: '· 禁止发布广告图文 (含Line、WeChat、QQ、网址、二维码等外链)\n' + '· 禁止发布诱导图文 (求网址、求软体等)\n' + '· 禁止发布儿童色情、兽*内容\n' + '· 禁止发布交易相关、政治等内容', + subContent: '请遵守社区规则,否则会被官方机器人拉黑', + showCancel: false, + confirmText: '我知道了', + ), + child: + const Text('规则', style: TextStyle(color: Color(0x8CFFFFFF))), + ), + 18.w.sizeBoxW, + ], + ), + //点空白处收起键盘 + body: InkWell( + enableFeedback: false, + onTap: logic.unfocus, + child: Container( + margin: EdgeInsets.symmetric(horizontal: 16.w), + width: double.infinity, + child: SafeArea( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: logic.type.children, + ), + ), + ), + //确定发布 + GestureDetector( + onTap: logic.publish, + child: Container( + margin: EdgeInsets.symmetric(vertical: 12.h), + padding: EdgeInsets.symmetric(vertical: 10.h), + alignment: Alignment.center, + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: const Text( + '确定发布', + style: TextStyle( + color: Colors.white, fontSize: 16, height: 27 / 14), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/community/publish_page/publish_rule_page.dart b/lib/hj_page/community/publish_page/publish_rule_page.dart new file mode 100644 index 0000000..966a7ae --- /dev/null +++ b/lib/hj_page/community/publish_page/publish_rule_page.dart @@ -0,0 +1,93 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; + +const homeRule = '''欢迎优秀的您加入黑料社,我们珍视每一位UP主,始终致力于为大家带来最优质的产品与服务。您的作品在黑料社的售卖无时间、次数限制,销售的次数越多,获得的收入就会越多。无论您是在睡觉还是在工作,它们都将持续为您带来源源不断的收入。 + +上传规则 +1.UP主及普通用户上传收费视频比例为2:1,即上传2个免费视频才可上传1个收费视频。 +2.原创举牌up主上传收费视频比例为1:1,即上传1个免费视频才可上传1个收费视频。 +3.视频清晰度需在360P以上,且时长不小于30秒。 +4.套图大于6张才可以设置价格。 +5.审核时间为48小时内,请在[创作中心]查收反馈。 +6.视频中的当事人须满18岁以上,且当事人同意视频被上传分享。 + +审核规则 +1.原创拍摄、原创剪辑作品,会更容易通过并获得官方推荐。 +2.禁止直接搬运网络视频,重复率高且不容易通过,多次违规将降低账号推荐权重。 +3.禁止在视频/图片中添加个人联系方式或插入广告网址将不会通过审核。 +4.禁止上传幼女、人兽、真实强奸等侵害他人的视频。 +5.加强用户隐私性,允许原创视频为人物面部等重要部分添加遮挡或马赛克。 +6.上传的视频内容不符合上传要求将不会通过审核,如若退回视频未作修改再次发起审核将禁止上传。 + + +定价规则 +1.发布内容默认为免费,用户可根据作品内容质量调整为金币视频。 +2.认证UP主发布原创举牌长视频,建议定价50-200金币 +3.原创举牌作品,建议定价30-50金币 +4.原创剪辑作品,建议定价10-20金币 +5.非原创短片,建议定价1-10金币'''; + +const squireRule = '''● 添加合适的话题:通过添加#话题 给内容打上标签,让你的内容被推荐给更多有共同语言的Tapper~ + +● 用心起标题:用10-20个字总结:”想要表达的观点“或”想解决的问题“ + +● 第一张图很重要:图文的第一张图会作为封面。图片需突出重点,放大关键信息。视频同理。 + +● 正文简单明了:段落清晰,一句一行!用标点符号、emoji表情进行分段分区! +'''; + +const treeCaveRule = '''抽取纸条规则: + +1.同一张纸条可以被多人抽取到 + +2.每个用户每天有5次免费抽取纸条机会,超出后每次抽取需要支付10积分; + +3.纸条抽取次数不可累计,如当天未使用凌晨00:00分自动清零重新增加5次 + + +投递纸条规则: + +1.用户放入纸条每次需要支付5积分 + +2.不能发布广告,下载链接等引流信息 + +3.不能如111,666这类的无意义信息 +'''; + +enum RuleType { + home(homeRule), + squire(squireRule), + treeCave(treeCaveRule); + + final String content; + const RuleType(this.content); +} + +class PublishRulePage extends StatelessWidget { + + final RuleType type; + + const PublishRulePage({super.key, this.type = RuleType.home}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('发布规则'), + ), + body: SingleChildScrollView( + // controller: controller, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: EasyRichText( + type.content, + defaultStyle: TextStyle(color: Colors.white.withValues(alpha: .6), fontSize: 14), + patternList: [ + EasyRichTextPattern(targetString: '认证UP主发布原创举牌长视频', style: TextStyle(color: Color(0xffF68216), fontSize: 14)), + EasyRichTextPattern(targetString: '抽取纸条规则:', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)), + EasyRichTextPattern(targetString: '投递纸条规则:', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)) + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/community/publish_page/widget/publish_widget.dart b/lib/hj_page/community/publish_page/widget/publish_widget.dart new file mode 100644 index 0000000..94a4f84 --- /dev/null +++ b/lib/hj_page/community/publish_page/widget/publish_widget.dart @@ -0,0 +1,428 @@ +import 'dart:io'; + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/tools_base/widget/add_media_source_button.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../home/widget/support_up_alert.dart'; +import '../chose_tag_page.dart'; +import '../publish_logic.dart'; +import 'video_cover_widget.dart'; + +/// 发布页各输入区统一的卡片底色 +const publishDecoration = BoxDecoration( + color: Color(0x0DFFFFFF), + borderRadius: BorderRadius.all(Radius.circular(10)), +); + +enum LinkType { topic, video, link } + +/// 添加视频 +class ChoseVideoWidget extends StatelessWidget { + ChoseVideoWidget({super.key}); + + final controller = Get.find(); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(top: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('添加视频', + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w500)), + 10.sizeBoxW, + Text('最大300M以内', + style: TextStyle(color: Color(0xff666666), fontSize: 12.sp)), + ], + ), + 14.h.sizeBoxH, + Obx(() { + final cellW = (Get.width - 52) / 3; + return Row(children: [ + 6.h.sizeBoxW, + if (controller.video.value.localPath == null) + AddMediaSourceButton( + width: cellW, + height: cellW, + onTap: controller.choseVideo, + isVideo: true) + else + _cover(cellW), + ]); + }) + ], + ), + ); + } + + /// 已选视频:封面 + 右上角删除 + Widget _cover(double size) { + return Container( + constraints: const BoxConstraints(maxWidth: 230, maxHeight: 219), + child: Stack( + children: [ + VideoCoverWidget(controller.video.value.localCover ?? '', + width: size, height: size, isLocal: true), + Positioned( + right: 6, + top: 6, + child: GestureDetector( + onTap: () => controller.deleteSelectVideo(), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .8), + borderRadius: BorderRadius.circular(10)), + child: + const Icon(Icons.close, size: 12, color: Color(0xff333333)), + ), + ), + ) + ], + ), + ); + } +} + +/// 选择标签 +class TopicWidget extends StatelessWidget { + TopicWidget({super.key}); + + final controller = Get.find(); + + @override + Widget build(BuildContext context) { + return Obx( + () => GestureDetector( + onTap: () async { + final model = await Get.to(ChoseTopicPage( + entry: controller.type.tag, selectTags: controller.topics)); + if (model is TagsBean) { + controller.topics + ..clear() + ..add(model); + } + }, + child: Container( + margin: EdgeInsets.only(top: 12.h), + padding: EdgeInsets.symmetric(horizontal: 14.w), + alignment: Alignment.centerLeft, + height: 60.h, + decoration: publishDecoration, + child: controller.topics.isEmpty ? _emptyTip() : _topicInfo(), + ), + ), + ); + } + + /// 未选标签时的占位提示 + Widget _emptyTip() { + return Row( + children: [ + Text("#", + style: TextStyle( + color: Color(0xff989898), + fontSize: 24.sp, + fontWeight: FontWeight.bold)), + 10.sizeBoxW, + Text('选择标签', + style: TextStyle( + color: Color(0xff989898), + fontSize: 12.sp, + fontWeight: FontWeight.w500)), + const Spacer(), + Icon(Icons.arrow_forward_ios, size: 16.sp, color: Color(0xffDCDCDC)), + ], + ); + } + + /// 已选标签:封面 + 名称(点击交给外层跳转,别再套手势,否则会截走外层的 onTap) + Widget _topicInfo() { + return Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: NetworkImageLoader( + imageUrl: controller.topics.first.coverImg ?? '', + width: 38, + height: 38), + ), + 5.sizeBoxW, + Text( + controller.topics.firstOrNull?.name ?? '', + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w500), + ), + Spacer(), + Icon(Icons.arrow_forward_ios, size: 16.sp, color: Color(0xffDCDCDC)), + ], + ); + } +} + +/// 输入标题 +class InputTitleWidget extends StatelessWidget { + InputTitleWidget({super.key}); + + final controller = Get.find(); + + @override + Widget build(BuildContext context) { + return Container( + height: 60.h, + margin: EdgeInsets.only(top: 14.h), + padding: EdgeInsets.symmetric(horizontal: 14.w), + alignment: Alignment.centerLeft, + decoration: publishDecoration, + child: TextField( + maxLines: 1, + textAlign: TextAlign.left, + textAlignVertical: TextAlignVertical.center, + style: TextStyle(color: Colors.white, fontSize: 16.sp, height: 22 / 15), + focusNode: controller.titleFouceN, + controller: controller.titleController, + decoration: InputDecoration( + border: InputBorder.none, + hintText: '请填写标题', + hintStyle: TextStyle( + color: Color(0xff525252), fontSize: 12.sp, height: 22 / 15), + contentPadding: EdgeInsets.zero, + isDense: true, + ), + ), + ); + } +} + +/// 输入正文(右下角实时字数) +class InputDescribeWidget extends StatelessWidget { + InputDescribeWidget({super.key}); + + final controller = Get.find(); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: () => controller.contentFouceN.requestFocus(), + child: Container( + constraints: BoxConstraints(minHeight: 171.h), + margin: const EdgeInsets.only(top: 14), + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 11.h), + decoration: publishDecoration, + child: Stack( + children: [ + TextField( + style: TextStyle(color: Colors.white, fontSize: 16.sp), + maxLength: 300, + maxLines: null, + focusNode: controller.contentFouceN, + controller: controller.describeController, + decoration: InputDecoration( + border: InputBorder.none, + hintStyle: TextStyle(color: Color(0xff525252), fontSize: 12.sp), + hintMaxLines: 10, + hintText: '有趣的介绍能让你的逼格提高N个档次!...', + counter: SizedBox(), + contentPadding: EdgeInsets.zero, + isDense: true, + ), + ), + Positioned( + bottom: 0, + right: 0, + // 字数计数:只监听不接管所有权(释放归 PublishLogic.onClose) + child: ValueListenableBuilder( + valueListenable: controller.describeController, + builder: (_, value, __) => Text( + '${value.text.length}/300', + style: TextStyle(color: Color(0xff757575), fontSize: 12), + ), + ), + ) + ], + ), + ), + ); + } +} + +/// 添加图片(最多 9 张,列表末位固定是「添加」占位) +class ChoseImageWidget extends StatelessWidget { + final String? hint; + final String? highlightText; + final Color? highlightTextColor; + final double? highlightTextSize; + + ChoseImageWidget( + {super.key, + this.hint, + this.highlightText, + this.highlightTextColor, + this.highlightTextSize}); + + final controller = Get.find(); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(top: 14), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + children: [ + Text('添加图片', + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w500)), + EasyRichText( + hint ?? '添加图集 图片数量最多上传9张哦~', + defaultStyle: + TextStyle(color: Color(0xff666666), fontSize: 12.sp), + patternList: [ + EasyRichTextPattern( + targetString: highlightText ?? '', + style: TextStyle( + color: highlightTextColor ?? Colors.white, + fontSize: highlightTextSize ?? 16.sp, + fontWeight: FontWeight.w500), + ) + ], + ), + ], + ), + 14.h.sizeBoxH, + LayoutBuilder(builder: (__, constrains) { + final cellW = (constrains.maxWidth - 16) / 3; + final images = controller.imgs; + return Obx( + () => Wrap( + runSpacing: 8, + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.start, + children: List.generate(images.length, (i) { + // 末位是占位项:满 10 项(9 张图)就不再给「添加」按钮 + if (i < images.length - 1) return _imgCell(images[i], cellW); + if (i == 9) return const SizedBox.shrink(); + return AddMediaSourceButton( + isVideo: false, + onTap: controller.choseImg, + height: cellW, + width: cellW, + title: "添加图片", + ); + }), + ), + ); + }), + ]), + ); + } + + /// 单张已选图片 + 右上角删除 + Widget _imgCell(String path, double size) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: size, + height: size, + child: Stack( + children: [ + Image.file(File(path), + width: size, height: size, fit: BoxFit.cover), + Positioned( + right: 6, + top: 6, + child: GestureDetector( + onTap: () => controller.imgs.remove(path), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .8), + borderRadius: BorderRadius.circular(10)), + alignment: Alignment.center, + child: const Icon(Icons.close, size: 14), + ), + ), + ) + ], + ), + ), + ); + } +} + +/// 设置价格(金币) +class SettingGoldWidget extends StatelessWidget { + SettingGoldWidget({super.key}); + + final controller = Get.find(); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + final res = await Get.bottomSheet( + SupportUPAlert( + isPublish: true, + selectCoin: int.tryParse(controller.gold.value ?? '')), + ); + controller.gold.value = res?.toString(); + }, + child: Container( + margin: const EdgeInsets.only(top: 18), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16), + decoration: publishDecoration, + child: Row( + children: [ + Image.asset('community_coin.webp'.communityPath, + width: 20, height: 20), + 10.sizeBoxW, + const Text('设置价格', + style: TextStyle( + color: Color(0xFF989898), + fontSize: 12, + fontWeight: FontWeight.w600)), + const Spacer(), + Obx( + () => Text( + controller.gold.value == null + ? '免费' + : '${controller.gold.value} 金币', + style: TextStyle( + color: controller.gold.value == null + ? Color(0xFF989898) + : AppColors.actionRed, + // color: Color(0xffF68216), + fontSize: 12, + fontWeight: FontWeight.w400), + ), + ), + 5.sizeBoxW, + const Icon(Icons.arrow_forward_ios, + color: Color(0xffDCDCDC), size: 12) + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/community/publish_page/widget/video_cover_widget.dart b/lib/hj_page/community/publish_page/widget/video_cover_widget.dart new file mode 100644 index 0000000..c7724f1 --- /dev/null +++ b/lib/hj_page/community/publish_page/widget/video_cover_widget.dart @@ -0,0 +1,57 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +/// 视频封面:本地/网络图 + 可选暗色遮罩 + 居中播放按钮 +/// [ratio] 传了就按比例撑开,不传则由外部约束决定尺寸 +class VideoCoverWidget extends StatelessWidget { + final String cover; + final double? ratio; + final double? width; + final double? height; + final bool isLocal; + final bool showMask; + final double borderRadius; + + const VideoCoverWidget( + this.cover, { + super.key, + this.ratio, + this.width, + this.height, + this.isLocal = false, + this.showMask = false, + this.borderRadius = 8, + }); + + @override + Widget build(BuildContext context) { + final maskW = width ?? double.infinity; + final maskH = height ?? double.infinity; + final child = Stack( + alignment: Alignment.center, + children: [ + if (isLocal) + Image.file(File(cover), + width: width, height: height, fit: BoxFit.cover) + else + NetworkImageLoader( + imageUrl: cover, width: maskW, height: maskH, borderRadius: 0), + if (showMask) + Container( + width: maskW, + height: maskH, + color: Colors.black.withValues(alpha: 0.4)), + Image.asset('circle_play.webp'.videoPath, width: 30, height: 30), + ], + ); + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: ratio == null + ? child + : AspectRatio(aspectRatio: ratio! > 0 ? ratio! : 1, child: child), + ); + } +} diff --git a/lib/hj_page/community/widget/community_middle_header.dart b/lib/hj_page/community/widget/community_middle_header.dart new file mode 100644 index 0000000..9dfce60 --- /dev/null +++ b/lib/hj_page/community/widget/community_middle_header.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import '../community/community_tab_logic.dart'; + +/// 社区首页的排序 tab 条,标题取自 CommunityTabLogic.sortTabs +class CommunityMiddleHeader extends StatefulWidget { + final Function(int sort) sortOnTap; + const CommunityMiddleHeader({super.key, required this.sortOnTap}); + + @override + State createState() => _CommunityMiddleHeaderState(); +} + +class _CommunityMiddleHeaderState extends State + with SingleTickerProviderStateMixin { + //别在这里另写一份标题 + final sorts = CommunityTabLogic.sortTabs.map((e) => e.name).toList(); + late final TabController _controller; + + @override + void initState() { + super.initState(); + //别用 late 惰性求值:万一没走 build 就 dispose,会在 dispose 里现建一个 Ticker + _controller = TabController(length: sorts.length, vsync: this); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: AppColors.primaryColor, + child: TabBar( + isScrollable: true, + labelPadding: EdgeInsets.symmetric(horizontal: 20.w), + padding: EdgeInsets.zero, + onTap: widget.sortOnTap, + tabAlignment: TabAlignment.center, + tabs: sorts + .map((e) => Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Text(e), + )) + .toList(), + indicatorWeight: 1, + unselectedLabelStyle: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 16.sp, + height: 1), + labelStyle: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 16.sp, + height: 1, + fontWeight: FontWeight.w600), + indicator: const BoxDecoration(), + controller: _controller, + ), + ); + } +} diff --git a/lib/hj_page/community/widget/community_post_widget.dart b/lib/hj_page/community/widget/community_post_widget.dart new file mode 100644 index 0000000..2982c6d --- /dev/null +++ b/lib/hj_page/community/widget/community_post_widget.dart @@ -0,0 +1,343 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../hj_model/video_model.dart'; +import '../../find/rank_module/rank_badge.dart'; +import '../../home/widget/collect_status_wrapper.dart'; +import '../community_tag_page/community_tag_page.dart'; +import '../post_detail_page/post_detail_page.dart'; +import 'post_user_info_view.dart'; +import 'post_word_rich_text.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; + +typedef OnItemCheckCallback = void Function( + VideoModel videoModel, bool isChecked); + +/// 社区帖子卡片,社区/搜索/个人主页/我的发布/收藏历史多处复用 +class CommunityPostWidget extends StatelessWidget { + final VideoModel videoModel; + + /// 详情页要拿整个列表做左右滑,由列表页传进来 + final List? videoModels; + final GestureTapCallback? clickCallback; + + /// 我的发布模式:顶部换成「发布/上传于…」+ 审核状态 + final bool isMyPublish; + final bool showTags; //是否展示标签 + final bool showLine; //展示分割线 + final bool showCheck; //展示勾选框 + final bool isChecked; //是否选中 + final OnItemCheckCallback? onItemCheck; + final int? rankIndex; //排行 + final EdgeInsetsGeometry? padding; + + const CommunityPostWidget({ + super.key, + required this.videoModel, + this.clickCallback, + this.isMyPublish = false, + this.videoModels, + this.showTags = true, + this.padding = const EdgeInsets.fromLTRB(16, 10, 16, 10), + this.showLine = true, + this.showCheck = false, + this.onItemCheck, + this.isChecked = false, + this.rankIndex, + }); + + //status: 0待审核 1已成功 2未通过 + String get _statusPrefix => videoModel.status == 1 ? '发布' : '上传'; + + String get _statusDesc => switch (videoModel.status) { + 0 => '待审核', + 1 => '已成功', + 2 => '未通过原因', + _ => '未知状态', //含 5 + }; + + Color get _statusColor => videoModel.status == 2 + ? const Color(0xffFD0563) + : const Color(0xff3476FF); + + bool get _showStatus => videoModel.status != 1 && !showCheck; + + @override + Widget build(BuildContext context) { + //视频帖的封面也在图集里,去重后图集只留纯图 + if (videoModel.sourceURL?.isNotEmpty == true) { + videoModel.seriesCover?.remove(videoModel.cover ?? ""); + } + return InkWell( + enableFeedback: false, + onTap: clickCallback ?? + () { + //在点击时算好:PostDetailPageArgument 内部要过滤整个列表,别丢进 page builder 里重复跑 + final argument = + PostDetailPageArgument(id: videoModel.id, models: videoModels); + Get.to(() => PostDetailPage(argument: argument)); + }, + child: Padding( + padding: padding ?? EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + //顶部:我的发布出「发布于…」+ 审核态,其余出用户信息行 + if (!isMyPublish) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded(child: PostUserInfoView(model: videoModel)), + if (rankIndex != null) rankBadge(rankIndex!), + ], + ), + ) + else ...[ + Row( + children: [ + Text( + '$_statusPrefix于${formatTimeTwo(videoModel.realCreateAt)}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(color: Color(0x8CFFFFFF), fontSize: 14), + ), + const Spacer(), + if (showCheck) + GestureDetector( + onTap: () => onItemCheck?.call(videoModel, !isChecked), + child: Stack( + alignment: Alignment.center, + children: [ + //白底垫在勾选图标下面,选中时圆圈内部才不透出背景 + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: + isChecked ? Colors.white : Colors.transparent, + shape: BoxShape.circle, + ), + ), + Icon( + isChecked + ? CupertinoIcons.checkmark_circle_fill + : CupertinoIcons.circle, + color: isChecked + ? const Color(0xffF68804) + : const Color(0xff999999), + ), + ], + ), + ), + if (_showStatus) + InkWell( + enableFeedback: false, + onTap: () async { + if (videoModel.status == 2) { + await CommonAlert.show( + title: "未过审原因", + content: '${videoModel.reason}', + showCancel: false, + confirmText: '我知道了', + ); + } + }, + child: Text(_statusDesc, + style: + TextStyle(color: _statusColor, fontSize: 14.sp)), + ), + ], + ), + 14.sizeBoxH, + ], + //标题(含置顶、精华标签) + if (videoModel.title?.trim().isNotEmpty == true) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: PostWordRichText(videoModel: videoModel), + ), + //封面:视频单图 / 单图 / 三宫格 + _coverView(), + 12.sizeBoxH, + //底部:浏览、评论、点赞 + 右侧话题 + Row( + children: [ + _countItem(videoModel.pageViewCount?.countStr, + "eye_grey.webp".commonImgPath), + 12.sizeBoxW, + _countItem(videoModel.commentCount?.countStr, + "count_msg_gray.png".communityPath), + 12.sizeBoxW, + CollectStatusWrapper( + videoModel: videoModel, + builder: () => _countItem( + videoModel.likeCount?.countStr, + videoModel.vidStatus?.hasLiked == true + ? "like_red.png".commonImgPath + : "like_empty_gray.png".communityPath, + ), + ), + const Spacer(), + if (showTags) _tagView(), + ], + ), + if (showLine) + Container( + height: 1, + margin: const EdgeInsets.only(top: 18), + color: Colors.white.withValues(alpha: .1), + ), + ], + ), + ), + ); + } + + /// 封面区:无图集的视频帖出单封面 + 播放按钮,单图直接铺,多图走三宫格 + Widget _coverView() { + final covers = videoModel.seriesCover ?? []; + final isVideo = videoModel.sourceURL?.isNotEmpty == true; + + if (covers.isEmpty && isVideo) { + return SizedBox( + height: 204, + child: Stack( + fit: StackFit.expand, + children: [ + NetworkImageLoader( + imageUrl: videoModel.cover ?? '', borderRadius: 6), + Center( + child: Image.asset("play_icon.png".communityPath, + width: 32, height: 32)), + Positioned(right: 0, top: 0, child: _coinBadge()), + ], + ), + ); + } + if (covers.length == 1) { + return SizedBox( + height: 204, + width: double.infinity, + child: NetworkImageLoader(imageUrl: covers[0], borderRadius: 6), + ); + } + + //视频帖的封面被上面 remove 掉了,格子数要把它加回来 + final coverCount = covers.length + (isVideo ? 1 : 0); + return GridView.builder( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + itemCount: min(3, coverCount), + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 5, + crossAxisSpacing: 5, + childAspectRatio: 1, + ), + itemBuilder: (context, index) { + final isLast = index == 2 || coverCount == index + 1; + //视频帖把视频封面排在最后一格,其余格按图集顺序取 + final imgPath = (isLast && isVideo) || index >= covers.length + ? (videoModel.cover ?? "") + : covers[index]; + //只有首尾两格带外圆角,中间格是直角 + final radius = isLast + ? const BorderRadius.only( + topRight: Radius.circular(6), bottomRight: Radius.circular(6)) + : index == 0 + ? const BorderRadius.only( + topLeft: Radius.circular(6), bottomLeft: Radius.circular(6)) + : BorderRadius.zero; + return Stack( + fit: StackFit.expand, + children: [ + NetworkImageLoader(imageUrl: imgPath, imgBorderRadius: radius), + if (isLast) Positioned(right: 0, top: 0, child: _coinBadge()), + if (isLast && isVideo) + Center( + child: Image.asset("play_icon.png".communityPath, width: 30)) + else if (isLast && coverCount > 3) + Positioned( + bottom: 6, + right: 6, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(30), + ), + child: Text( + "+${coverCount - 3}", + style: const TextStyle( + fontSize: 10, + color: Color(0xff141414), + fontWeight: FontWeight.w400, + height: 1.6), + ), + ), + ), + ], + ); + }, + ); + } + + //金币帖右上角的角标 + Widget _coinBadge() { + if (videoModel.permission != 1) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: const BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(6), bottomLeft: Radius.circular(6)), + color: Color(0xffDB361F), + ), + child: Image.asset("coin.webp".commonImgPath, width: 14, height: 14), + ); + } + + Widget _countItem(String? title, String icon) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset(icon, width: 20, height: 20), + const SizedBox(width: 4), + Text(title ?? "", + style: const TextStyle(color: Color(0xff999999), fontSize: 12)), + ], + ); + } + + /// 帖子标签 + Widget _tagView() { + if (videoModel.tags?.isNotEmpty != true) return const SizedBox(); + final tagsBean = videoModel.tags!.first; + tagsBean.newsType = videoModel.newsType; + return GestureDetector( + onTap: () => Get.to(() => CommunityTagDetailPage(model: tagsBean)), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: const Color(0x0DFFFFFF)), + child: Text( + '#${tagsBean.name ?? ''}', + style: const TextStyle(color: Color(0x8CFFFFFF), fontSize: 12), + ), + ), + ); + } +} diff --git a/lib/hj_page/community/widget/community_sort_header.dart b/lib/hj_page/community/widget/community_sort_header.dart new file mode 100644 index 0000000..f705b4c --- /dev/null +++ b/lib/hj_page/community/widget/community_sort_header.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; + +class CommunitySortHeader extends StatefulWidget { + final Function(int sort) sortOnTap; + final TabController? controller; + const CommunitySortHeader({ + super.key, + required this.sortOnTap, + this.controller, + }); + + @override + State createState() => _CommunitySortHeaderState(); +} + +class _CommunitySortHeaderState extends State + with SingleTickerProviderStateMixin { + final sorts = ['最多收藏', '最新上架', '最新热评']; + late TabController _controller; + + /// 外部传进来的归外部释放,只有自己 new 的才由自己 dispose + late final bool _isMine; + + @override + void initState() { + super.initState(); + //在 initState 定死归属,别用 late 惰性求值——那时读到的可能是 rebuild 后的新 widget + _isMine = widget.controller == null; + _controller = + widget.controller ?? TabController(length: sorts.length, vsync: this); + } + + @override + void dispose() { + if (_isMine) _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.primaryColor, + ), + child: TabBar( + isScrollable: true, + labelPadding: EdgeInsets.symmetric(horizontal: 12.w), + padding: EdgeInsets.zero, + onTap: widget.sortOnTap, + tabAlignment: TabAlignment.center, + tabs: sorts + .map((e) => Padding( + padding: + EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h), + child: Text(e), + )) + .toList(), + indicatorWeight: 1, + unselectedLabelStyle: TextStyle( + color: Colors.white.withValues(alpha: .6), + fontSize: 16.sp, + height: 1), + labelStyle: TextStyle( + color: Colors.white, + fontSize: 16.sp, + height: 1, + fontWeight: FontWeight.w600), + // 无指示器:原 ContainerTabIndicator 为全透明,选中态仅靠文字加粗变白 + indicator: const BoxDecoration(), + controller: _controller, + ), + ); + } +} diff --git a/lib/hj_page/community/widget/follow_loading_button.dart b/lib/hj_page/community/widget/follow_loading_button.dart new file mode 100644 index 0000000..cacb7e9 --- /dev/null +++ b/lib/hj_page/community/widget/follow_loading_button.dart @@ -0,0 +1,47 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; + +/// 关注 / 收藏按钮,加载中转菊花 +class FollowLoadingButton extends StatelessWidget { + final bool? isFollow; + final bool? isLoading; + final bool? isCollectStyle; + final double radius; + const FollowLoadingButton({ + super.key, + this.isFollow, + this.isLoading, + this.isCollectStyle, + this.radius = 6, + }); + + @override + Widget build(BuildContext context) { + final followed = isFollow == true; + //已关注是灰底灰字 10 号,未关注是红底白字 12 号 + final fg = followed ? const Color(0xff989898) : Colors.white; + return Container( + width: 70, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius), + color: followed ? const Color(0x0DFFFFFF) : AppColors.actionRed, + ), + child: isLoading == true + ? const CupertinoActivityIndicator(color: Colors.white, radius: 8) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(followed ? Icons.check : Icons.add, size: 14, color: fg), + const SizedBox(width: 4), + Text( + isCollectStyle == true ? "收藏" : (followed ? "已关注" : "关注"), + style: TextStyle(color: fg, fontSize: followed ? 10 : 12), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/community/widget/pic_simple_cell.dart b/lib/hj_page/community/widget/pic_simple_cell.dart new file mode 100644 index 0000000..b01d3a1 --- /dev/null +++ b/lib/hj_page/community/widget/pic_simple_cell.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../hj_model/video_model.dart'; +import '../../find/pic_collect/pics_detail_page.dart'; + +/// 图集卡片:封面 + 底部渐变条(浏览数 / 图片数) + 标题 +class PicSimpleCell extends StatelessWidget { + final VideoModel? videoModel; + final int textLines; + const PicSimpleCell({super.key, this.videoModel, this.textLines = 2}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => + Get.to(PicsDetailPage(id: videoModel?.id), preventDuplicates: false), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + NetworkImageLoader( + imageUrl: videoModel?.cover ?? '', + borderRadius: 8, + alignment: Alignment.topCenter, + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: EdgeInsets.fromLTRB(6, 8, 6, 6), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0), + Colors.black.withValues(alpha: 0.6), + ], + ), + ), + child: Row( + children: [ + Image.asset("eye_white.webp".commonImgPath, width: 16), + 1.sizeBoxW, + Text( + "${videoModel?.playCount?.countStr}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white, fontSize: 10), + ), + Spacer(), + Container( + padding: + EdgeInsets.symmetric(horizontal: 4, vertical: 1), + child: Row( + children: [ + Image.asset("icon_pic.png".communityPath, + width: 16), + 2.sizeBoxW, + Text( + videoModel?.seriesCover?.length.toString() ?? + "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + "${videoModel?.title ?? ""}\n", + maxLines: textLines, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/community/widget/post_user_info_view.dart b/lib/hj_page/community/widget/post_user_info_view.dart new file mode 100644 index 0000000..3c0ea79 --- /dev/null +++ b/lib/hj_page/community/widget/post_user_info_view.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/community/widget/follow_loading_button.dart'; +import 'package:hgdj/hj_page/home/widget/collect_status_wrapper.dart'; +import 'package:hgdj/hj_page/home/widget/user_name_view.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +//帖子发布者信息:头像 + 昵称 + 发布时间,详情模式额外带关注按钮 +class PostUserInfoView extends StatefulWidget { + final VideoModel model; + final bool isDetailStyle; // 帖子详情模式 + final double? avatarSize; // 头像边长,默认 40 + + const PostUserInfoView({ + super.key, + required this.model, + this.avatarSize, + this.isDetailStyle = false, + }); + + @override + State createState() => _PostUserInfoViewState(); +} + +class _PostUserInfoViewState extends State { + VideoModel get model => widget.model; + + Publisher? get publisher => model.publisher; + + bool _isLoading = false; + + @override + Widget build(BuildContext context) { + //无发布者、黄游帖不展示 + if (publisher == null || model.newsType == 'ADULT_GAME') { + return const SizedBox.shrink(); + } + final size = widget.avatarSize ?? 40; + return Row( + children: [ + //头像 + InkWell( + enableFeedback: false, + onTap: () => pushToPersonCenter(publisher?.uid), + child: NetworkImageLoader( + imageUrl: publisher?.portrait ?? "", + width: size, + height: size, + borderRadius: size / 2, + ), + ), + 10.sizeBoxW, + //昵称 + 发布时间 + Expanded(child: widget.isDetailStyle ? _nameDetail() : _nameNormal()), + //关注按钮:仅详情模式,套 CollectStatusWrapper 同步全局关注态 + if (widget.isDetailStyle) + CollectStatusWrapper( + videoModel: model, + builder: () => InkWell( + enableFeedback: false, + onTap: _toggleFollow, + child: FollowLoadingButton( + isFollow: publisher?.hasFollowed ?? false, + isLoading: _isLoading), + ), + ), + ], + ); + } + + //关注/取关:先锁定目标值,避免 await 期间 eventBus 改写 hasFollowed 导致取反基准漂移 + void _toggleFollow() async { + if (_isLoading) return; + final willFollow = !(publisher?.hasFollowed ?? false); + setState(() => _isLoading = true); + + final res = await MineService.getFollow(publisher?.uid, willFollow); + if (res) publisher?.hasFollowed = willFollow; + + _isLoading = false; + if (mounted) setState(() {}); + } + + //列表样式 + Widget _nameNormal() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UserNameView( + nameColor: Colors.white, + name: publisher?.name, + isOfficial: publisher?.officialCert, + ), + 2.sizeBoxH, + Text( + formatTimeTwo(model.reviewAt), + style: const TextStyle(color: Color(0xff999999), fontSize: 12), + ), + ], + ); + } + + //详情样式:昵称后跟官方认证标 + Widget _nameDetail() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: UserNameView( + nameColor: Colors.white, + name: publisher?.name, + isOfficial: publisher?.officialCert, + ), + ), + if (publisher?.officialCert == true) ...[ + 4.sizeBoxW, + Image.asset("official_icon.png".commonImgPath, + width: 22, height: 22), + ], + ], + ), + 2.sizeBoxH, + Text( + formatTimeTwo(model.reviewAt), + style: const TextStyle(color: Color(0x8C999999), fontSize: 12), + ), + ], + ); + } +} diff --git a/lib/hj_page/community/widget/post_word_rich_text.dart b/lib/hj_page/community/widget/post_word_rich_text.dart new file mode 100644 index 0000000..8c539eb --- /dev/null +++ b/lib/hj_page/community/widget/post_word_rich_text.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/widget_util.dart'; + +import '../../../hj_model/video_model.dart'; + +/// 帖子标题:置顶 / 精华标签跟标题混排,用 WidgetSpan 才能跟着文字换行 +class PostWordRichText extends StatelessWidget { + final VideoModel? videoModel; + + const PostWordRichText({super.key, this.videoModel}); + + @override + Widget build(BuildContext context) { + return Text.rich( + TextSpan( + children: [ + if (videoModel?.isTopping ?? false) _badge('置顶'), + if (videoModel?.chosen ?? false) _badge('精华'), + TextSpan( + text: videoModel?.title ?? '', + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ], + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ); + } + + WidgetSpan _badge(String text) => WidgetSpan( + child: Container( + width: 32, + height: 18, + alignment: Alignment.topCenter, + margin: const EdgeInsets.only(right: 4), + decoration: BoxDecoration( + color: const Color(0xffF52C56), + borderRadius: BorderRadius.circular(2)), + child: + Text(text, style: textStyle(12, Colors.white, FontWeight.w500)), + ), + ); +} diff --git a/lib/hj_page/drama/drama_detail_logic.dart b/lib/hj_page/drama/drama_detail_logic.dart new file mode 100644 index 0000000..2173f04 --- /dev/null +++ b/lib/hj_page/drama/drama_detail_logic.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/drama_media_info.dart'; +import 'package:hgdj/hj_model/list_base_model.dart'; +import 'package:hgdj/hj_model/media_content.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/tools_base/cache/history/drama_resume_store.dart'; +import 'package:hgdj/hj_page/drama/view/drama_episode_sheet.dart'; +import 'package:hgdj/hj_page/drama/view/drama_speed_sheet.dart'; +import 'package:hgdj/hj_utils/api_service/drama_service.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; + +/// 短剧二级页:同一部剧连播,退出本页就回到 Feed 的逐剧模式 +class DramaDetailLogic extends GetxController { + // GetBuilder 局部刷新 id:翻页只刷信息流,倒计时只刷底部条 + static const idFeed = 'feed'; + static const idBar = 'bar'; + + // ===== 外部传入 ===== + final DramaMediaInfo? drama; // 列表带过来的剧信息(先渲染,详情回来再覆盖) + + /// 指定先播第几集(下标);<0 表示不指定,用本地续播位置 + final int initialIndex; + + /// 指定先播哪一集(分集 id)。优先级高于上面两者—— + /// 缓存页只存了分集 id,且那一集常落在后面的分页里,按下标定位不到 + final String? initialContentId; + + /// [initialIndex] 那一集的起播秒数,由一级页刷剧模式透传;其余情况为 0 + int _startSeconds; + + DramaDetailLogic( + this.drama, { + this.initialIndex = -1, + this.initialContentId, + int initialSeconds = 0, + }) : _startSeconds = initialSeconds; + + // ===== 状态 ===== + bool isFirstLoaded = false; + bool _isLoading = false; // 分集分页加载中,防并发 + + // ===== 数据 ===== + DramaMediaInfo? _detail; + + //播放器要的 VideoModel:只在拉到分集时转一次并缓存。 + //每次 build 现转会换新对象,播放器 didUpdateWidget 里跟着换 model,详情(权限)就会被反复重拉 + List episodeVideos = []; + + //剧信息以详情为准,详情没回来先用列表带过来的 + DramaMediaInfo? get info => _detail ?? drama; + + //分集列表挂在剧信息上 + List get episodes => info?.episodeList ?? []; + + bool get isLast => pageIndex >= episodes.length - 1; + + // ===== 交互态 ===== + int pageIndex = 0; + int nextSeconds = 0; // >0 时底部条显示「Ns后播放下一集」 + double speed = 1.0; // 播放倍速,整页共用:切集沿用,退页即回默认 + + // ===== Controller ===== + late PageController pageCtr; + final ValueNotifier isScrolling = ValueNotifier(false); // 上下滑动切集时隐藏进度条 + + @override + void onInit() { + super.onInit(); + pageIndex = initialIndex < 0 ? 0 : initialIndex; + pageCtr = PageController(initialPage: pageIndex); + loadDetail(); + } + + Future loadDetail() async { + isFirstLoaded = false; + update(); + try { + //三件事互不依赖:续播位置读本地盘,详情和分集都只认构造带进来的剧 id, + //一起发出去,首屏从「读盘→详情→分集」三次串行缩成一次并发。 + //两个接口必须走 Future.wait 一起等:分开 await 的话前一个抛异常后, + //后一个没人接的 Future 就成了 unhandled async error(签名失败时两个会一起抛) + final resumeTask = dramaResume.load(); // 读本地盘,内部已兜住异常 + final resp = await Future.wait([ + DramaService.fetchDetail(drama?.id), + DramaService.fetchEpisodeList( + drama?.id ?? '', 1, drama?.episodePageSize ?? 30), + ]); + await resumeTask; + _detail = resp[0] as DramaMediaInfo? ?? drama; + //分集要等详情落定再挂:info 会从 drama 换成 _detail,挂早了就写进被丢弃的那个对象里 + _applyEpisodes(resp[1] as ListBaseModel?); + if (initialContentId?.isNotEmpty == true) { + //外部点的就是这一集(如缓存页点某一集),续播位置和下标都让路 + _locateTo(await _indexOfContent(initialContentId)); + } else if (initialIndex < 0) { + _locateTo(await _indexOfContent(dramaResume.of(info?.id)?.contentId)); + } else { + //外部指定了集数(如 Feed 里第一集播完 → 进来接着播下一集),别被续播位置顶走 + _locateTo(initialIndex); + } + _touchHistory(); + } catch (e) { + debugLog('短剧详情获取失败:$e'); + } + if (isClosed) return; // 请求期间可能已退页,别对已释放的 controller 刷新 + isFirstLoaded = true; + update(); + } + + /// 进来就算看过一次:观看时间刷成现在,历史列表里排到最前,进度不动。 + /// 不能只靠播放器落进度——秒退、或者进来就撞付费墙时它一条都不记(见 _saveResume 的三道闸) + void _touchHistory() { + final list = episodes; + dramaResume.markWatched( + mediaId: info?.id, + contentId: + list.isEmpty ? null : list[pageIndex.clamp(0, list.length - 1)].id, + drama: info, + ); + } + + /// 把并发拉回来的第一页分集挂到最终的剧对象上 + void _applyEpisodes(ListBaseModel? res) { + final media = info; + if (media == null) return; + media.episodeList = res?.list; + media.episodeCurPage = 1; + media.haxNextEpisode = res?.hasNext ?? false; + episodeVideos = (media.episodeList ?? []).map(media.toVideoModel).toList(); + } + + /// 开短剧卡之后:整部剧一起放行。只重拉一次剧详情拿最新权限,分集不逐页重拉—— + /// 付费角标就地清掉,具体哪一集能不能播,等翻到它时 _loadEpisode 拉分集详情为准 + Future onCardUnlocked() async { + final detail = await DramaService.fetchDetail(drama?.id); + if (isClosed) return; + final media = info; + if (media == null) return; + if (detail != null) { + //分集那几个字段是本页自己维护的,别被新详情清空 + detail.episodeList = media.episodeList; + detail.episodeCurPage = media.episodeCurPage; + detail.haxNextEpisode = media.haxNextEpisode; + _detail = detail; + } + //置空而不是改成 bought:开了卡就是不该再挂付费标,真实状态以分集详情为准 + for (final episode in info?.episodeList ?? []) { + if (episode.accessType == 'coin' || episode.accessType == 'card') + episode.accessType = null; + } + update([idFeed]); + } + + /// 快翻到列表尾部时续拉下一页,别让连播在分页边界断掉。 + /// 原地 addAll 而不是新建列表:选集面板持有的是同一个 List 引用,换了对象它就永远看不到新数据。 + /// 只追加新的,也不整表重建 episodeVideos——重建会把在播的那几集换成新对象,播放器跟着重拉一遍详情 + Future _loadMore() async { + final media = info; + if (media == null || _isLoading || media.haxNextEpisode != true) return; + _isLoading = true; + final page = (media.episodeCurPage ?? 1) + 1; + final res = await DramaService.fetchEpisodeList( + media.id ?? '', page, media.episodePageSize); + _isLoading = false; + if (isClosed) return; + //拉空/失败都要落 haxNextEpisode,否则 _resumeIndex 的补页循环会一直转 + if (res?.list?.isNotEmpty != true) { + media.haxNextEpisode = false; + return; + } + final more = res!.list!; + (media.episodeList ??= []).addAll(more); + media.episodeCurPage = page; + media.haxNextEpisode = res.hasNext ?? false; + episodeVideos.addAll(more.map(media.toVideoModel)); + update([idFeed]); + } + + /// 某一集在列表里的下标。目标集常落在后面的分页里,这里边翻页边找—— + /// 分集按集数有序,找到那一页就停,拉的页数和「先算出第几集再拉够长度」一样多, + /// 却不依赖「第 N 集就在第 N 个位置」(集数跳号就会算少)。没传/找不到(已下架)都回 0 + Future _indexOfContent(String? contentId) async { + if (contentId == null) return 0; + var index = episodes.indexWhere((e) => e.id == contentId); + while (index < 0 && !isClosed && info?.haxNextEpisode == true) { + final before = episodes.length; + await _loadMore(); + if (episodes.length == before) break; // 没拉到新数据,别死转 + index = episodes.indexWhere((e) => e.id == contentId); + } + return index < 0 ? 0 : index; + } + + /// 定位到某一集(具体秒数由播放器按本地记录续)。 + /// 下标必须钳在已加载范围内:只有 1 集的剧收到「播下一集」时会越界,PageView 直接崩。 + /// isClosed 也要挡:补分页那几个请求还在飞时用户退了页,pageCtr 已在 onClose 里释放, + /// 走到下面的 dispose 就是二次释放 + void _locateTo(int index) { + if (isClosed || episodes.isEmpty) return; + final target = index.clamp(0, episodes.length - 1); + if (target == pageIndex) return; + pageIndex = target; + //错误页重试进来时 PageView 可能还挂着旧 controller,那种情况只能跳页,dispose 会崩在 detach 上 + if (pageCtr.hasClients) { + pageCtr.jumpToPage(target); + return; + } + pageCtr.dispose(); + pageCtr = PageController(initialPage: target); + } + + void onPageChanged(int index) { + _clearNextTip(); + if (pageIndex == index) return; + pageIndex = index; + update([idFeed]); + if (episodes.length - index <= 5) _loadMore(); + } + + /// 播放器报来的「距结束还剩 N 秒」:连播时底部条提前倒数,不是播完再等 5 秒 + void onRemaining(int seconds) { + if (isLast) return; // 最后一集不提示下一集 + if (nextSeconds == seconds) return; + nextSeconds = seconds; + update([idBar]); + } + + /// 本集播完:连播直接切下一集;最后一集播完就停,不回第一集 + void onCompleted() { + _clearNextTip(); + if (isLast) return; + pageCtr.animateToPage( + pageIndex + 1, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + void _clearNextTip() { + if (nextSeconds == 0) return; + nextSeconds = 0; + update([idBar]); + } + + /// 打开倍速面板。倍速挂在页面上不是某一集上,切集后新起的播放器也按它播 + Future openSpeedSheet() async { + final value = await DramaSpeedSheet.show(speed: speed); + if (isClosed || value == null || value == speed) return; + speed = value; + update(); // 右上角按钮文案 + 各集播放器一起刷 + } + + /// 打开选集面板,选中后跳到对应集 + Future openEpisodeSheet() async { + _clearNextTip(); + final index = await DramaEpisodeSheet.show( + drama: info, + episodes: episodes, // 与 logic 共用同一个 List 实例,续拉的新集面板里能直接看到 + selectedIndex: pageIndex, + onLoadMore: _loadMore, + ); + //选集面板是异步的,回来时页面可能已经退掉,pageCtr 已释放 + if (isClosed || index == null || index == pageIndex) return; + pageCtr.jumpToPage(index); + } + + /// 取走带进来的起播秒数:只给落地那一集,且只给一次—— + /// PageView 滑远会把那一集的 State 回收,再滑回来重建时不清零的话会又 seek 回原位, + /// 而滑回来的口径是从头重看 + int takeStartSeconds(int index) { + if (index != initialIndex || _startSeconds <= 0) return 0; + final seconds = _startSeconds; + _startSeconds = 0; + return seconds; + } + + /// 上下滑动切集时隐藏进度条。只认起止,中间的 update 通知不用管 + void onScroll(ScrollNotification n) { + if (n is ScrollStartNotification) { + isScrolling.value = true; + } else if (n is ScrollEndNotification) { + isScrolling.value = false; + } + } + + // GetX 释放只走 onClose(),定时器/控制器都在这里清 + @override + void onClose() { + pageCtr.dispose(); + isScrolling.dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/drama/drama_detail_page.dart b/lib/hj_page/drama/drama_detail_page.dart new file mode 100644 index 0000000..2573719 --- /dev/null +++ b/lib/hj_page/drama/drama_detail_page.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/drama_media_info.dart'; +import 'package:hgdj/hj_page/drama/drama_detail_logic.dart'; +import 'package:hgdj/hj_page/drama/view/drama_bottom_bar.dart'; +import 'package:hgdj/hj_page/drama/view/drama_video_player.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +/// 短剧二级页:竖划切集 + 底部剧集条(选集/自动续播) +class DramaDetailPage extends StatefulWidget { + final DramaMediaInfo? drama; + + /// 指定进来先播第几集(下标);<0 表示不指定,交给续播位置决定 + final int initialIndex; + + /// 一级页刷剧模式带进来的秒数:只作用在 [initialIndex] 那一集。 + /// 从历史记录/收藏进来的不传,那边的位置由本地续播记录决定 + final int initialSeconds; + + /// 指定先播哪一集(分集 id),优先级高于 [initialIndex] 和续播位置。缓存页点某一集进来用 + final String? initialContentId; + + const DramaDetailPage({ + super.key, + this.drama, + this.initialIndex = -1, + this.initialSeconds = 0, + this.initialContentId, + }); + + @override + State createState() => _DramaDetailPageState(); +} + +class _DramaDetailPageState extends State with UniqueTagMixin { + //底部剧集条整体高度(36 内容 + 上下 6 外边距),播放器进度条据此让位 + static const _barHeight = 48.0; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.primaryColor, + body: GetBuilder( + tag: uniqueTag, // 可重复 push,按实例隔离 + init: DramaDetailLogic( + widget.drama, + initialIndex: widget.initialIndex, + initialContentId: widget.initialContentId, + initialSeconds: widget.initialSeconds, + ), + builder: (logic) { + if (!logic.isFirstLoaded) return const LoadingCenterWidget(); + if (logic.episodeVideos.isEmpty) + return CErrorWidget(retryOnTap: logic.loadDetail); + return Stack( + fit: StackFit.expand, + children: [ + _episodeFeed(), + _backButton(), + _speedButton(logic), + Positioned(left: 0, right: 0, bottom: 0, child: _bottomBar()), + ], + ); + }, + ), + ); + } + + /// 竖向翻集:当前页变化时只 update([idFeed]),各 item 重算 isCurrentPage + Widget _episodeFeed() { + return GetBuilder( + tag: uniqueTag, + id: DramaDetailLogic.idFeed, + builder: (logic) => NotificationListener( + onNotification: (n) { + logic.onScroll(n); + return false; + }, + child: PageView.builder( + controller: logic.pageCtr, + onPageChanged: logic.onPageChanged, + physics: const ClampingScrollPhysics(), + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + itemCount: logic.episodeVideos.length, + itemBuilder: (context, index) { + //每集的 VideoModel.id 都是剧 id,key 必须用子集 id,否则 PageView 会跨集复用 State + return DramaVideoPlayer( + key: ValueKey(logic.episodes[index].id ?? index), + index: index, + isCurrentPage: logic.pageIndex == index, + videoInfo: logic.episodeVideos[index], + showEntry: false, // 已经在剧里了,剧名不带箭头、底部条由本页画 + isScrolling: logic.isScrolling, + progressBottom: _barHeight + Get.mediaQuery.padding.bottom, + speed: logic.speed, + //带进来的秒数只给落地那一集,其余从本地续播位置起播 + startSeconds: logic.takeStartSeconds(index), + onCompleted: logic.onCompleted, + onRemaining: logic.onRemaining, + onCardUnlocked: logic.onCardUnlocked, + ); + }, + ), + ), + ); + } + + /// 底部剧集条:正常展示「观看完整剧集·全N集」,距本集结束 5 秒内切成「Ns后播放下一集」 + Widget _bottomBar() { + return SafeArea( + top: false, + child: GetBuilder( + tag: uniqueTag, + id: DramaDetailLogic.idBar, + builder: (logic) { + final seconds = logic.nextSeconds; + return DramaBottomBar( + highlight: seconds > 0 ? '${seconds}s后' : null, + text: seconds > 0 + ? '播放下一集' + : '观看完整剧集·全${logic.info?.totalEpisode ?? logic.episodes.length}集', + onTap: logic.openEpisodeSheet, + ); + }, + ), + ); + } + + /// 右上角倍速入口(设计稿 NavBar:秒表 18 + 间距 4 + 12/中等,无底无框) + Widget _speedButton(DramaDetailLogic logic) { + return Positioned( + top: 20, + right: 16, + child: SafeArea( + //高度对齐左上角返回键(24 图标 + IconButton 的 8 内边距 = 40),两边看着在一条线上 + child: SizedBox( + height: 40, + child: Center( + child: InkWell( + enableFeedback: false, + onTap: logic.openSpeedSheet, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + //导出的图标描边是 #292929,设计稿里是白的,运行时染白 + SvgPicture.asset( + 'stopwatch.svg'.videoPath, + width: 18, + height: 18, + colorFilter: + const ColorFilter.mode(Colors.white, BlendMode.srcIn), + ), + 4.sizeBoxW, + Text( + '${logic.speed}x', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + height: 18 / 12, + fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _backButton() { + return Positioned( + top: 20, + child: SafeArea( + child: IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + shadows: [ + Shadow(color: Colors.black, offset: Offset(1, 1), blurRadius: 1) + ], + ), + onPressed: Get.back, + ), + ), + ); + } +} diff --git a/lib/hj_page/drama/drama_feed_logic.dart b/lib/hj_page/drama/drama_feed_logic.dart new file mode 100644 index 0000000..2cb8f4c --- /dev/null +++ b/lib/hj_page/drama/drama_feed_logic.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/drama/drama_models.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/tools_base/cache/history/drama_resume_store.dart'; +import 'package:hgdj/hj_utils/api_service/drama_service.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:uuid/uuid.dart'; + +/// AI短剧信息流:一条 = 一部剧的第 1 集,竖划换下一部剧。 +/// 排序、去重、已看沉底全在服务端,**没有页码**——每次请求就是下一批,客户端不得二次排序, +/// 也不做二次去重(服务端目前会重发同一部剧,去重就意味着列表不增长、用户直接划不动) +class DramaFeedLogic extends GetxController { + // GetBuilder 局部刷新 id:当前页变化时只刷信息流 + static const idFeed = 'feed'; + + // ===== 外部传入 ===== + final TabController? superTabCtr; // 外层频道 tab,用于切到本 tab 时补加载 + final int? tabIndex; + + DramaFeedLogic({this.superTabCtr, this.tabIndex}); + + // ===== 状态 ===== + bool isFirstLoaded = false; // 首屏是否加载完成(false 时显示 loading) + bool isLoading = false; // 防并发翻页 + bool hasNext = true; + + /// 本批的幂等键:网络重试沿用,拿到结果才作废 + String? _requestId; + + // ===== 数据 ===== + List? list; + + // ===== 交互态 ===== + int pageIndex = 0; // 当前显示第几条 + + // ===== Controller ===== + late PageController pageCtr; + final ValueNotifier isScrolling = ValueNotifier(false); // 上下滑动切剧时隐藏进度条 + + @override + void onInit() { + super.onInit(); + pageCtr = PageController(initialPage: pageIndex); + superTabCtr?.addListener(_onTabChanged); + //续播位置是本地记的,进来就把表读上来。不用 await:读的是本地 sqflite,比首批网络请求快得多, + //播放器起播时(要等 /media_content/info 回来)必然已就绪 + dramaResume.load(); + loadData(); + } + + @override + void onClose() { + superTabCtr?.removeListener(_onTabChanged); + pageCtr.dispose(); + isScrolling.dispose(); + super.onClose(); + } + + // 切到本 tab 且还没数据就补一次 + void _onTabChanged() { + if (superTabCtr?.index == tabIndex && list?.isEmpty == true) loadData(); + } + + /// [isRefresh] 下拉刷新时清空重来,否则往后追加 + Future loadData({bool isRefresh = true}) async { + if (isLoading) return; + if (!isRefresh && !hasNext) return; + isLoading = true; + try { + final resp = await DramaService.fetchFeed( + size: 20, requestId: _requestId ??= const Uuid().v4()); + if (resp != null) _requestId = null; // 成功才作废;失败重试沿用同一个 id,避免服务端当成两批 + //拿到什么收什么,不按剧 id 去重:服务端反复下发同一部剧时,去重会让列表不增长, + //用户停在最后一条划不动、onPageChanged 也不再触发,就彻底卡死了。 + //宁可刷到重复的剧,也不能卡住——重复的 key 由页面用「剧 id + 下标」避开 + final more = []; + for (final item in resp?.list ?? const []) { + final media = item.media; + if (media == null) continue; // 缺 media 的脏数据,别在信息流里留个空位 + more.add(media.toVideoModel(item.content)); + } + hasNext = resp?.hasNext ?? false; + list = isRefresh ? more : [...?list, ...more]; + } catch (e) { + list ??= []; + debugLog('短剧信息流获取失败:$e'); + } + isLoading = false; + isFirstLoaded = true; + update(); + } + + void onPageChanged(int index) { + if (pageIndex != index) { + pageIndex = index; + update([idFeed]); // 只刷信息流,各 item 重算 isCurrentPage + } + // 快到底了先把下一批拉上,别等滑到最后一条才请求 + if (hasNext && (list?.length ?? 0) - index <= 6) loadData(isRefresh: false); + } +} diff --git a/lib/hj_page/drama/drama_feed_page.dart b/lib/hj_page/drama/drama_feed_page.dart new file mode 100644 index 0000000..c2fec18 --- /dev/null +++ b/lib/hj_page/drama/drama_feed_page.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/drama/drama_feed_logic.dart'; +import 'package:hgdj/hj_page/drama/view/drama_video_player.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +/// AI短剧信息流页:竖划一部换一部,每条播该剧第 1 集 +class DramaFeedPage extends StatefulWidget { + final TabController? superTabCtr; + final int? tabIndex; + + const DramaFeedPage({super.key, this.superTabCtr, this.tabIndex}); + + @override + State createState() => _DramaFeedPageState(); +} + +class _DramaFeedPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, // 多 tab 并存,按实例隔离 + init: DramaFeedLogic( + superTabCtr: widget.superTabCtr, tabIndex: widget.tabIndex), + builder: (logic) { + final Widget content; + if (!logic.isFirstLoaded) { + content = const LoadingCenterWidget(); + } else if (logic.list?.isNotEmpty != true) { + content = CErrorWidget(retryOnTap: () => logic.loadData()); + } else { + content = _buildFeed(logic); + } + return Material(color: Colors.transparent, child: content); + }, + ); + } + + /// 信息流局部刷新:当前页变化时只 update([idFeed]),各 item 重算 isCurrentPage + Widget _buildFeed(DramaFeedLogic logic) { + return RefreshIndicator( + onRefresh: () => logic.loadData(), + child: GetBuilder( + tag: uniqueTag, + id: DramaFeedLogic.idFeed, + // 上下滑动开始/结束 → 切换 isScrolling,各播放器据此隐藏/显示进度条 + builder: (logic) => NotificationListener( + onNotification: (n) { + if (n is ScrollStartNotification) { + logic.isScrolling.value = true; + } else if (n is ScrollEndNotification) { + logic.isScrolling.value = false; + } + return false; + }, + child: PageView.builder( + onPageChanged: logic.onPageChanged, + physics: const ClampingScrollPhysics(), + controller: logic.pageCtr, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + itemCount: logic.list?.length ?? 0, + itemBuilder: (context, index) { + final video = logic.list![index]; + //推荐流不去重,同一部剧会反复下发,光用剧 id 会撞 key(PageView 直接抛 Duplicate keys); + //带上下标才唯一。也不能只用下标——下拉刷新后同一个位置换了别的剧, + //key 不变 PageView 就会复用 State,播放器不重建,播的还是上一部 + return DramaVideoPlayer( + key: ValueKey('${video.dramaInfo?.id ?? ''}#$index'), + index: index, + isCurrentPage: logic.pageIndex == index, + videoInfo: video, + isScrolling: logic.isScrolling, + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/drama/drama_list_logic.dart b/lib/hj_page/drama/drama_list_logic.dart new file mode 100644 index 0000000..6d1b954 --- /dev/null +++ b/lib/hj_page/drama/drama_list_logic.dart @@ -0,0 +1,141 @@ +import 'package:hgdj/hj_model/drama/drama_models.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/drama_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +/// 标签页本地排序(专题接口仅用于热门短剧橱窗) +const _tagSortTabs = [ + SortTab('热门推荐', 1), + SortTab('最新上架', 2), + SortTab('最多收藏', 4), +]; + +/// 专题接口失败时的兜底(与文档系统专题对齐) +final _fallbackTopics = [ + DramaTopic( + topicId: 'hot_recommend', + name: '热门推荐', + topicType: 'SYSTEM', + systemKey: 'HOT_RECOMMEND', + sort: 500, + ), + DramaTopic( + topicId: 'latest', + name: '最新上架', + topicType: 'SYSTEM', + systemKey: 'LATEST', + sort: 400, + ), + DramaTopic( + topicId: 'everyone_likes', + name: '大家爱看', + topicType: 'SYSTEM', + systemKey: 'EVERYONE_LIKES', + sort: 300, + ), +]; + +class DramaListLogic extends ListBaseLogic { + /// 传了就是标签页:只出这个标签下的剧,改走片库搜索;不传是短剧频道里的热门橱窗 + final String? tagId; + + DramaListLogic({this.tagId}); + + int sortIndex = 0; + + /// 热门短剧橱窗专题 Tab(接口下发) + List topics = []; + bool topicsReady = false; + + bool get _isTag => tagId?.isNotEmpty == true; + + List get sortTitles { + if (_isTag) return _tagSortTabs.map((e) => e.name).toList(); + return topics.map((e) => e.name ?? '').where((e) => e.isNotEmpty).toList(); + } + + String? get _currentTopicId { + if (_isTag || topics.isEmpty) return null; + if (sortIndex < 0 || sortIndex >= topics.length) return null; + final id = topics[sortIndex].topicId; + return id?.isNotEmpty == true ? id : null; + } + + @override + bool get isLoading => + _isTag ? super.isLoading : (!topicsReady || super.isLoading); + + @override + void onInit() { + super.onInit(); + if (_isTag) { + topicsReady = true; + loadData(); + } else { + _loadTopicsThenList(); + } + } + + Future _loadTopicsThenList() async { + try { + final list = await DramaService.fetchTopics(); + topics = (list == null || list.isEmpty) ? List.of(_fallbackTopics) : list; + } catch (_) { + topics = List.of(_fallbackTopics); + } + topicsReady = true; + sortIndex = 0; + update(); + await loadData(); + } + + //isRefresh 下拉刷新/加载更多;showLoading 切换排序时先清空显示 loading + //返回 Future:CErrorWidget 的重试按钮靠返回值出 loading 和防连点 + Future loadData({bool isRefresh = true, bool showLoading = false}) { + if (!_isTag && !topicsReady) return Future.value(); + if (showLoading) { + dataList = null; + update(); + } + return fetchData(isRefresh: isRefresh, fetch: _fetch); + } + + //两个接口返回的都是 ListBaseModel,取完统一转 VideoModel: + //卡片复用 VideoSimpleCell,一次性转好,滚动时不再反复构造 + Future<(List?, bool)> _fetch(int page) async { + if (_isTag) { + final sort = _tagSortTabs[sortIndex].sort; + final res = await DramaService.searchLibrary( + page: page, + size: 20, + tagIds: [tagId!], + sortType: sort, + ); + return ( + res?.list?.map((e) => e.toVideoModel(null)).toList(), + res?.hasNext ?? false + ); + } + + final topicId = _currentTopicId; + if (topicId == null) return ([], false); + final res = + await DramaService.fetchTopicWorks(topicId, page: page, size: 20); + return ( + res?.list?.map((e) => e.toVideoModel(null)).toList(), + res?.hasNext ?? false + ); + } + + void changeSort(int index) { + if (sortIndex == index) return; + if (_isTag) { + if (index < 0 || index >= _tagSortTabs.length) return; + } else { + if (index < 0 || index >= topics.length) return; + } + sortIndex = index; + loadData(showLoading: true); + } +} diff --git a/lib/hj_page/drama/drama_list_page.dart b/lib/hj_page/drama/drama_list_page.dart new file mode 100644 index 0000000..8e4a90b --- /dev/null +++ b/lib/hj_page/drama/drama_list_page.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/drama/drama_detail_page.dart'; +import 'package:hgdj/hj_page/drama/drama_list_logic.dart'; +import 'package:hgdj/hj_page/home/home_cell_style/video_simple_cell.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +/// 排序 tab + 双列剧集宫格。两种用法: +/// 不传 [tag] = 短剧频道里的「热门短剧」橱窗(嵌在 tab 里,无标题栏); +/// 传 [tag] = 短剧标签页(独立路由、带标题栏,只出该标签下的剧) +class DramaListPage extends StatefulWidget { + final TagsBean? tag; + + const DramaListPage({super.key, this.tag}); + + //标签页是独立路由,靠这个名字区分 + static const tagRoute = '/DramaTagPage'; + + /// 进短剧标签页。跳转一律走它,别在调用方自己写 Get.to——不显式传 routeName 时 + /// GetX 会用闭包的 runtimeType 拼出 `/DramaListPage Function()` 作为路由名 + static Future? toTag(TagsBean tag) => + Get.to(() => DramaListPage(tag: tag), + routeName: tagRoute, preventDuplicates: true); + + @override + State createState() => _DramaListPageState(); +} + +class _DramaListPageState extends State with UniqueTagMixin { + /// 排序 Tab 超过 4 个时是否展开为多行;默认折叠只显示第一行 + bool _sortExpanded = false; + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: DramaListLogic(tagId: widget.tag?.id), + tag: uniqueTag, + builder: (logic) { + final content = ColoredBox( + color: const Color(0xFF05000E), + child: Column( + children: [ + widget.tag == null + ? _buildSortTabBar(logic) + : _buildTagSortTabBar(logic), + Expanded(child: _buildBody(logic)), + ], + ), + ); + //橱窗是嵌在短剧频道 tab 里的,外面已经有页面壳,别再套一层 Scaffold + if (widget.tag == null) return content; + return Scaffold( + appBar: AppBar(title: Text(widget.tag?.name ?? '')), + body: content, + ); + }, + ); + } + + /// 标签页排序:无底色,选中白字,未选中 #59FFFFFF,中间 12 高竖线 + Widget _buildTagSortTabBar(DramaListLogic logic) { + final titles = logic.sortTitles; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < titles.length; i++) ...[ + if (i > 0) + Container( + width: 1, + height: 12, + margin: const EdgeInsets.symmetric(horizontal: 18), + color: const Color(0x1AFFFFFF), + ), + GestureDetector( + onTap: () => logic.changeSort(i), + child: Text( + titles[i], + style: TextStyle( + fontSize: 14, + color: i == logic.sortIndex + ? Colors.white + : const Color(0x59FFFFFF), + ), + ), + ), + ], + ], + ), + ); + } + + /// 「热门短剧」下排序 Tab:72×26 胶囊;居左;一行最多 4 个,超出可展开/收起 + Widget _buildSortTabBar(DramaListLogic logic) { + final titles = logic.sortTitles; + final needToggle = titles.length > 4; + final visibleCount = (!needToggle || _sortExpanded) ? titles.length : 4; + + // 按每行 4 个切行 + final rows = >[]; + for (var i = 0; i < visibleCount; i += 4) { + final end = i + 4 > visibleCount ? visibleCount : i + 4; + rows.add([for (var j = i; j < end; j++) j]); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + children: [ + for (var r = 0; r < rows.length; r++) ...[ + if (r > 0) const SizedBox(height: 8), + _buildSortRow(logic, titles, rows[r], + hasToggle: _rowHasToggle(needToggle, rows, r)), + ], + // 展开且末行已满 4 个时,收起按钮另起一行跟在最后 + if (needToggle && + _sortExpanded && + rows.isNotEmpty && + rows.last.length == 4) ...[ + const SizedBox(height: 8), + Row(children: [_buildExpandToggle()]), + ], + ], + ), + ); + } + + bool _rowHasToggle(bool needToggle, List> rows, int r) { + if (!needToggle) return false; + if (!_sortExpanded) return r == 0; + return r == rows.length - 1 && rows[r].length < 4; + } + + Widget _buildSortRow( + DramaListLogic logic, + List titles, + List indexes, { + required bool hasToggle, + }) { + return Row( + children: [ + for (var k = 0; k < indexes.length; k++) ...[ + if (k > 0) const SizedBox(width: 8), + _buildSortTab(logic, indexes[k], titles[indexes[k]], + expand: !hasToggle), + ], + if (hasToggle) ...[ + const SizedBox(width: 8), + _buildExpandToggle(), + ], + ], + ); + } + + Widget _buildSortTab(DramaListLogic logic, int index, String title, + {bool expand = false}) { + final selected = index == logic.sortIndex; + final tab = GestureDetector( + onTap: () => logic.changeSort(index), + child: Container( + width: expand ? double.infinity : 72, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? const Color(0xFFF68804) : const Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w500 : FontWeight.w400, + color: selected ? Colors.white : const Color(0x8CFFFFFF), + ), + ), + ), + ); + return expand ? Expanded(child: tab) : tab; + } + + Widget _buildExpandToggle() { + return GestureDetector( + onTap: () => setState(() => _sortExpanded = !_sortExpanded), + child: Container( + width: 26, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(4), + ), + child: AnimatedRotation( + turns: _sortExpanded ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Image.asset('chevron_down.webp'.commonImgPath, + width: 16, height: 16), + ), + ), + ); + } + + /// 进短剧二级页 + void _openDrama(VideoModel model) { + //不指定集数:有续播位置就从上次看到的那一集接着播 + Get.to( + () => DramaDetailPage(drama: model.dramaInfo), + ); + } + + Widget _buildBody(DramaListLogic logic) { + if (logic.isLoading) return const LoadingCenterWidget(); + if (logic.isEmptyData) + return CErrorWidget(retryOnTap: () => logic.loadData()); + final list = logic.dataList!; + return pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onRefresh: (_) => logic.loadData(), + onLoading: (_) => logic.loadData(isRefresh: false), + child: GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisSpacing: 6, + mainAxisSpacing: 12, + crossAxisCount: 2, + childAspectRatio: 168 / 266, + ), + itemCount: list.length, + itemBuilder: (context, index) { + final VideoModel model = list[index]; + return VideoSimpleCell( + videoModel: model, + textLines: 1, + coverRightText: model.dramaInfo?.episodeNumberStatus, + showLevelIcon: false, + onTap: () => _openDrama(model), + onTagTap: (tag) => DramaListPage.toTag(tag), + ); + }, + ), + ); + } +} diff --git a/lib/hj_page/drama/view/drama_bottom_bar.dart b/lib/hj_page/drama/view/drama_bottom_bar.dart new file mode 100644 index 0000000..7a204e0 --- /dev/null +++ b/lib/hj_page/drama/view/drama_bottom_bar.dart @@ -0,0 +1,79 @@ +import 'dart:ui'; + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +/// 短剧底部条:信息流「查看完整短剧」入口 / 二级页「观看完整剧集」选集入口 + 自动播放倒计时。 +/// 不跟左右菜单一起收:纯净模式(眼睛按钮)下也常驻,这是进整部剧的唯一入口 +class DramaBottomBar extends StatelessWidget { + final String? leadingIcon; // 信息流左侧的剧集图标(资源名),二级页不传 + final String? highlight; // 橙色高亮前缀,如「5s后」 + final String text; + final String tailIcon; // 资源名:二级页向上展开选集,信息流向右跳详情 + final VoidCallback? onTap; + + const DramaBottomBar({ + super.key, + required this.text, + this.leadingIcon, + this.highlight, + this.tailIcon = 'chevron_up.webp', + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: onTap, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(6)), + //半透明底 + 背景模糊,纯色压不出那个质感。 + //模糊范围被外层圆角裁住,只糊药丸这一小块,不会整帧走离屏 + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), + child: Container( + //高度写死 36(设计稿):不能让「有没有左图标」「字号多大」把它撑变—— + //二级页按 DramaDetailPage._barHeight 给它预留空间,浮动就会和进度条挤到一起 + height: 36, + color: const Color(0xff2A2A2A).withValues(alpha: 0.5), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + if (leadingIcon != null) ...[ + //设计稿图标框 24,图形自带 4px 透明留白,所以与文字的视觉间距只有 10 + Image.asset(leadingIcon!.videoPath, width: 24, height: 24), + 10.sizeBoxW, + ], + Expanded( + child: EasyRichText( + '${highlight ?? ''}$text', + maxLines: 1, + overflow: TextOverflow.ellipsis, + defaultStyle: + const TextStyle(color: Colors.white, fontSize: 13), + patternList: [ + //highlight 是「5s后」这种自己拼的倒计时,不会有正则元字符 + if (highlight?.isNotEmpty == true) + EasyRichTextPattern( + targetString: highlight, + matchOption: 'first', + style: const TextStyle(color: AppColors.actionRed), + ), + ], + ), + ), + Image.asset(tailIcon.videoPath, width: 18, height: 18), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/drama/view/drama_episode_sheet.dart b/lib/hj_page/drama/view/drama_episode_sheet.dart new file mode 100644 index 0000000..297202b --- /dev/null +++ b/lib/hj_page/drama/view/drama_episode_sheet.dart @@ -0,0 +1,290 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_model/drama_media_info.dart'; +import 'package:hgdj/hj_model/media_content.dart'; +import 'package:hgdj/hj_page/live/live_widget.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; + +/// 短剧选集面板:剧名 + 连载状态 + 分段(1-30/31-60…) + 集数宫格 +/// 选中某集后 Get.back(result: 该集下标) +class DramaEpisodeSheet extends StatefulWidget { + final DramaMediaInfo? drama; // 剧信息(剧名/连载状态/总集数) + final List episodes; + final int selectedIndex; + + /// 翻到未加载的分段时续拉下一页 + final Future Function()? onLoadMore; + + const DramaEpisodeSheet({ + super.key, + this.drama, + required this.episodes, + this.selectedIndex = 0, + this.onLoadMore, + }); + + static Future show({ + DramaMediaInfo? drama, + required List episodes, + int selectedIndex = 0, + Future Function()? onLoadMore, + }) { + return Get.bottomSheet( + DramaEpisodeSheet( + drama: drama, + episodes: episodes, + selectedIndex: selectedIndex, + onLoadMore: onLoadMore, + ), + isScrollControlled: true, + ); + } + + @override + State createState() => _DramaEpisodeSheetState(); +} + +class _DramaEpisodeSheetState extends State { + //每段集数,与接口分页 DramaMediaInfo.episodePageSize 一致:第 i 页正好是第 i 段 + static const _segmentSize = 30; + + late int segmentIndex = widget.selectedIndex ~/ _segmentSize; // 默认定位到当前集所在段 + + bool _isLoading = false; // 正在补拉目标段的数据 + + //分段要按**总集数**分,不能按已加载数:只拉了首页就只显示一段,用户根本不知道后面还有 + int get _total => widget.drama?.totalEpisode ?? widget.episodes.length; + + //「连载中」「已完结」,后端下发 + String get _updateDesc => widget.drama?.updateDesc ?? ''; + + int get segmentCount => (_total / _segmentSize).ceil(); + + //当前段对应的集数区间(下标)。上限取已加载数:翻到还没拉回来的段时 _end 会小于 _start, + //由 _episodeGrid 按「本段为空」处理(不能用 clamp 夹,lower>upper 会抛 ArgumentError) + int get _start => segmentIndex * _segmentSize; + + int get _end { + final end = _start + _segmentSize; + return end > widget.episodes.length ? widget.episodes.length : end; + } + + @override + Widget build(BuildContext context) { + return Container( + height: Get.height * 0.63, + padding: const EdgeInsets.fromLTRB(16, 18, 16, 18), + decoration: const BoxDecoration( + color: AppColors.primaryColor, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Center(child: SheetHandleBar()), + 18.sizeBoxH, + // 剧名 + Text( + widget.drama?.title ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 16, + fontWeight: FontWeight.w500), + ), + 18.sizeBoxH, + // 连载中·全x集 + EasyRichText( + '$_updateDesc·共$_total集', + defaultStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.35), + fontSize: 12, + height: 1.5), + patternList: [ + //updateDesc 是后端下发的、可能带正则元字符,必须开 hasSpecialCharacters 转义; + //空串更要拦住——空 targetString 会编译成 (),在每个位置都命中 + if (_updateDesc.isNotEmpty) + EasyRichTextPattern( + targetString: _updateDesc, + matchOption: 'first', + hasSpecialCharacters: true, + style: const TextStyle(color: AppColors.actionRed), + ), + ], + ), + 18.sizeBoxH, + if (segmentCount > 1) ...[ + _segmentBar(), + 18.sizeBoxH, + ], + Expanded(child: _episodeGrid()), + ], + ), + ); + } + + /// 分段切换:1-30 / 31-60 … + Widget _segmentBar() { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(segmentCount, (i) { + final from = i * _segmentSize + 1; + final to = ((i + 1) * _segmentSize).clamp(0, _total); + final isSelected = i == segmentIndex; + return InkWell( + enableFeedback: false, + onTap: () => _selectSegment(i), + child: Padding( + padding: EdgeInsets.only(right: i == segmentCount - 1 ? 0 : 24), + child: Text( + '$from-$to', + style: TextStyle( + color: + Colors.white.withValues(alpha: isSelected ? 0.9 : 0.35), + fontSize: 14, + height: 1.43, + ), + ), + ), + ); + }), + ), + ); + } + + /// 切段:目标段的数据没拉够就一直补,直到够了或后端没有更多。 + /// 与 logic 共用同一个 List 实例,拉完刷新自己就能看到新集 + Future _selectSegment(int index) async { + setState(() => segmentIndex = index); + final need = (index + 1) * _segmentSize; + if (_isLoading || widget.episodes.length >= need) return; + setState(() => _isLoading = true); + while (widget.episodes.length < need) { + final before = widget.episodes.length; + await widget.onLoadMore?.call(); + if (!mounted) return; + if (widget.episodes.length == before) break; // 一集没多,说明到底了 + } + setState(() => _isLoading = false); + } + + /// accessType 是本集的「解锁门槛/状态」:free 免费 / coin 需金币 / card 需短剧卡 / bought 已买过 + Widget? _badgeOf(MediaContent model) { + switch (model.accessType) { + case 'coin': + return _corner('金币', color: const Color(0xffFF9000)); + case 'bought': + return _corner('已购'); + default: // free / card 及未知类型不挂角标 + return null; + } + } + + /// 格子左上角贴边角标。短剧的样式跟 ACG 的 FreeBadge 不一样,各画各的不共用; + /// 不传 color 就是已解锁那支红渐变(已购/短剧卡同款) + Widget _corner(String text, {Color? color}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color, + gradient: color != null + ? null + : const LinearGradient( + begin: Alignment.bottomRight, + end: Alignment.topLeft, + colors: [Color(0xffFF4D4D), Color(0xffFF6E6E)], + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(6), bottomRight: Radius.circular(9)), + ), + child: Text(text, + style: + const TextStyle(color: Colors.white, fontSize: 10, height: 1.6)), + ); + } + + Widget _episodeGrid() { + final count = _end - _start; + //本段还没拉回来:转圈;拉完了还是空说明这段确实没有(后端集数对不上) + if (count <= 0) { + return Center( + child: _isLoading + ? const CupertinoActivityIndicator( + color: AppColors.actionRed, radius: 10) + : Text('暂无剧集', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.35), fontSize: 12)), + ); + } + return GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + crossAxisSpacing: 13, + mainAxisSpacing: 12, + childAspectRatio: 1, + ), + itemCount: count, + itemBuilder: (context, index) { + final realIndex = _start + index; + return _episodeItem(realIndex); + }, + ); + } + + Widget _episodeItem(int index) { + final model = widget.episodes[index]; + final isSelected = index == widget.selectedIndex; + //角标只区分金币/已购;免费和短剧卡不挂标(设计稿要求) + final badge = _badgeOf(model); + return InkWell( + enableFeedback: false, + onTap: () => Get.back(result: index), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: isSelected ? const Color(0x0DF68804) : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isSelected + ? AppColors.actionRed + : Colors.white.withValues(alpha: 0.1)), + ), + child: Stack( + alignment: Alignment.center, + children: [ + if (badge != null) Positioned(top: 0, left: 0, child: badge), + Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${model.episodeNumber ?? index + 1}', + style: TextStyle( + color: isSelected + ? AppColors.actionRed + : Colors.white.withValues(alpha: 0.9), + fontSize: isSelected ? 14 : 16, + fontWeight: FontWeight.w500, + ), + ), + if (isSelected) ...[ + 2.sizeBoxW, + const AudioWaveView(color: AppColors.actionRed, height: 10), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/drama/view/drama_menu.dart b/lib/hj_page/drama/view/drama_menu.dart new file mode 100644 index 0000000..c6a38d1 --- /dev/null +++ b/lib/hj_page/drama/view/drama_menu.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/drama/drama_detail_page.dart'; +import 'package:hgdj/hj_page/drama/drama_list_page.dart'; +import 'package:hgdj/hj_page/short_video/view/media_action_menu.dart'; +import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../tools_base/widget/shrink_wrap.dart'; + +/// 短剧操作台:左侧「剧名 + 第N集 + 标签」+ 右侧动作菜单 + 中间暂停播放按钮。 +/// 点赞/收藏/评论/分享全部挂在**剧**上,下载则是当前这一集;短剧没有作者人设 +class DramaMenu extends StatefulWidget { + final VideoPlayerController? playerCtr; + final VideoModel? videoModel; + final VoidCallback? onSwitchLine; //切换 CDN 线路后重新起播 + final bool showEntry; //剧名是否带箭头可点进二级页;二级页自身传 false,否则会重复入栈 + + /// 信息栏底部要距播放器底多远。由播放器按「进度条 + 剧集条 + 底部安全区」算好传进来—— + /// 写死过一次(40+48),二级页在带手势条的机型上安全区一顶就和进度条压到一起了 + final double bottomInset; + + const DramaMenu({ + super.key, + this.playerCtr, + this.videoModel, + this.onSwitchLine, + this.showEntry = true, + required this.bottomInset, + }); + + @override + State createState() => _DramaMenuState(); +} + +class _DramaMenuState extends State { + //信息栏各行的上间距(设计稿 剧名→标签行 12) + double get gapValue => 12; + + VideoModel? get _video => widget.videoModel; + + @override + void initState() { + super.initState(); + VideoMenuSwitch.addListen(_menuListen); + } + + @override + void dispose() { + VideoMenuSwitch.removeLister(_menuListen); + super.dispose(); + } + + void _menuListen(bool isShow) { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(bottom: 4), + child: Stack( + alignment: Alignment.center, + children: [ + Container( + //外层那个 bottom:4 已经占掉一部分,这里补齐到调用方要求的总距离 + padding: EdgeInsets.only(bottom: widget.bottomInset - 4), + child: Row( + children: [ + Expanded( + child: MenuReveal( + visible: VideoMenuSwitch.isShow, + offset: const Offset(-0.1, 0), // 隐藏时向左滑出 + child: _buildLeftPanel(), + ), + ), + const SizedBox(width: 8), + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + MediaActionMenu( + style: MediaStyle.Drama, + videoModel: _video, + playerCtr: widget.playerCtr, + onSwitchLine: widget.onSwitchLine, + ), + ], + ), + 12.sizeBoxW, + ], + ), + ), + if (widget.playerCtr != null) + CenterPlayPause(controller: widget.playerCtr!), + ], + ), + ); + } + + // ============ 左侧信息栏(剧名 / 第N集 + 标签)============ + + Widget _buildLeftPanel() { + return Container( + padding: const EdgeInsets.only(left: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + //剧名,二级页里不带箭头也不可点 + _buildTitleItem(), + //第N集 + 标签 + _buildTagItem(), + ], + ), + ); + } + + Widget _buildTitleItem() { + final title = _video?.title?.trim() ?? ''; + if (title.isEmpty) return const SizedBox(); + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), + ), + ), + if (widget.showEntry) + Image.asset('chevron_right.webp'.videoPath, width: 20, height: 20), + ], + ); + return Container( + padding: EdgeInsets.only(top: gapValue), + child: widget.showEntry + ? InkWell(enableFeedback: false, onTap: _openDrama, child: content) + : content, + ); + } + + Widget _buildTagItem() { + final episodeText = Text( + "第${_video?.episodeNo ?? 1}集", + style: const TextStyle( + color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500), + ); + if (_video?.tags?.isNotEmpty != true) { + return Container( + padding: EdgeInsets.only(top: gapValue), child: episodeText); + } + return Container( + padding: EdgeInsets.only(top: gapValue), + child: Row( + children: [ + episodeText, + 6.sizeBoxW, + Expanded(child: _tagWrap()), + ], + ), + ); + } + + Widget _tagWrap() { + return ShrinkWrap( + spacing: 0, + runSpacing: 6, + maxLines: 1, + children: _video?.tags + ?.map((e) => InkWell( + enableFeedback: false, + onTap: () { + widget.playerCtr?.pause(); + //短剧标签要出的是「剧」,不能用视频标签页——那边的卡片点开跳的是视频详情页 + DramaListPage.toTag(e); + }, + child: Container( + padding: const EdgeInsets.fromLTRB(8, 3, 8, 3), + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: const Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: Text(e.name ?? '', + style: const TextStyle( + color: Color(0xffffffff), fontSize: 11)), + ), + )) + .toList() ?? + [], + ); + } + + /// 进短剧二级页,定位到当前这一集。只有刷剧模式挂得上(二级页 showEntry=false) + void _openDrama() { + widget.playerCtr?.pause(); + final index = (_video?.episodeNo ?? 1) - 1; + //把这一刻的集数+秒数带进连播模式,写法与 DramaVideoPlayer._openDrama 一致 + Get.to( + () => DramaDetailPage( + drama: _video?.dramaInfo, + initialIndex: index < 0 ? 0 : index, + initialSeconds: widget.playerCtr?.value.position.inSeconds ?? 0, + ), + ); + } +} diff --git a/lib/hj_page/drama/view/drama_paywall_view.dart b/lib/hj_page/drama/view/drama_paywall_view.dart new file mode 100644 index 0000000..998f135 --- /dev/null +++ b/lib/hj_page/drama/view/drama_paywall_view.dart @@ -0,0 +1,266 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; +import 'package:flutter/material.dart'; +import 'package:hgdj/alert/video/buy_vip_alert.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_model/drama/drama_models.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/mine/mine_vip/pay_order_source.dart'; +import 'package:hgdj/hj_page/mine/mine_vip/widgets/coin_pay_bottom_sheet.dart'; +import 'package:hgdj/hj_utils/pay/pay_manager.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/net/base_resp_bean.dart'; +import 'package:hgdj/tools_base/net/net_code.dart'; +import 'package:uuid/uuid.dart'; + +/// 短剧付费墙:只有「金币解锁」和「开通短剧卡」两个按钮,没有第三种选择。 +/// 标题/价格/按钮文案一律用服务端下发的 [DramaPaywall],客户端不自己拼。 +class DramaPaywallView extends StatefulWidget { + final VideoModel? video; + + /// 解锁成功(金币解锁 / 开卡后已放行):调用方重新拉分集详情并续播。 + /// [byCard] true = 走的开短剧卡,整部剧一起放行,不只是当前这一集 + final Future Function({bool byCard}) onUnlocked; + + const DramaPaywallView({ + super.key, + required this.video, + required this.onUnlocked, + }); + + @override + State createState() => _DramaPaywallViewState(); +} + +class _DramaPaywallViewState extends State { + bool _busy = false; // 解锁/开卡进行中,防连点 + + /// 已经解锁成功、正在等调用方拉分集详情并把播放器建起来。 + /// 那段有网络往返 + initialize,好几秒,按钮还杵在那儿用户会以为没成功接着点 + bool _unlocking = false; + + /// 本次解锁的幂等键:失败保留、成功清空。同一次解锁的重试(含充值后自动重试)复用它, + /// 服务端直接回首次结果,不会重复扣金币 + String? _requestId; + + String? get _mediaId => widget.video?.dramaInfo?.id; + + String? get _contentId => widget.video?.dramaEpisode?.id; + + DramaPaywall? get _paywall => widget.video?.dramaEpisode?.paywall; + + //钱包给的最新余额,顶掉付费墙里那份快照 + int? _newBalance; + + //进墙那一刻的快照打底,之后谁刷了钱包就跟谁:只认快照的话, + //切去「我的」充完金币再切回来还是旧数,够钱也会被当成余额不足。 + //反过来钱包要「我的」等页面才会去拉,冷启动直奔短剧时是 null,不能拿它兜底 + int get _balance => _newBalance ?? _paywall?.coinBalance ?? 0; + + String get _coin => '$_balance'; + + @override + void initState() { + super.initState(); + globalStore.addListener(_onWalletChanged); + } + + @override + void dispose() { + globalStore.removeListener(_onWalletChanged); + super.dispose(); + } + + //别处充值/花钱都会 refreshWallet 并通知,跟着把墙上的数顶掉 + void _onWalletChanged() { + final amount = globalStore.wallet?.amount; + if (amount == null || amount == _balance) return; + setState(() => _newBalance = amount); + } + + PayOrderTrackInfo get _orderTrack => PayOrderTrackInfo( + sourcePage: PaySourcePage.dramaPaywall, + //sourceRef/videoId 与其它入口同口径:来源那条内容的 id,短剧就是剧 id + sourceRef: _mediaId, + videoId: _mediaId, + mediaId: _mediaId, + contentId: _contentId, + checkoutContextId: _paywall?.checkoutContextId, + ); + + /// 金币解锁:余额够直接下单;不够先弹充值,充完自动再解锁一次(复用同一个 checkoutContextId) + Future _onCoinUnlock() async { + if (_busy) return; + _busy = true; + try { + final need = _paywall?.unlockCoin ?? 0; + if (_balance < need) { + //看着不够先跟钱包对一次:手上这份可能是几分钟前进墙时的快照, + //直接信它会把已经在别处充过钱的人又推去充值 + await globalStore.refreshWallet(); + if (!mounted) return; + } + if (_balance < need) { + await CoinPayBottomSheet.show( + sourcePage: PaySourcePage.dramaPaywall, orderTrack: _orderTrack); + await globalStore.refreshWallet(); + if (!mounted || _balance < need) return; // 没充成功,停在付费墙 + } + await _unlock(); + } finally { + _busy = false; + } + } + + /// [allowRetry] 只允许「充值成功后自动再解锁一次」,避免服务端一直回 8000 时来回打。 + /// loading / 8000 提示 / 8005 当成功都在 PayManager 里,这里只管短剧自己的收尾 + Future _unlock({bool allowRetry = true}) async { + final requestId = _requestId ??= const Uuid().v4(); + //PayManager 的回调是同步签名,把后续异步动作接出来再 await, + //否则 _onCoinUnlock 的 finally 会在解锁真正走完前就把 _busy 放开 + Future? next; + await PayManager().buy( + _mediaId, + ProductType.mediaAll, // 短剧买单集:productID 是剧 id,具体哪一集看 contentID + contentID: _contentId, + checkoutContextId: _paywall?.checkoutContextId, + requestId: requestId, + source: 'drama_unlock', + jsonTransformation: (json) => DramaUnlockResult.fromJson(json), + //余额不足要就地弹金币支付,跳充值页会把播放器和当前集一起丢掉 + jumpWalletOnInsufficient: false, + onSuccess: (resp) { + _requestId = null; + // 服务端扣完的余额直接写回,再拉一次钱包对齐(与下载授权同一套写法)。 + // 8005 走到这里时 data 是错误体不是订单结果,所以要判类型 + final unlock = resp.data; + if (unlock is DramaUnlockResult && unlock.coinBalance != null) { + _newBalance = unlock.coinBalance; + globalStore.wallet?.amount = unlock.coinBalance; + } + globalStore.refreshWallet(); + setState(() => _unlocking = true); + next = widget.onUnlocked().whenComplete(_stopUnlocking); + }, + onFailure: (resp) => next = _onUnlockFailed(resp, allowRetry: allowRetry), + ); + await next; + } + + /// 解锁失败:只处理余额不足,其余保留 _requestId,重试复用同一个键别重复扣费 + Future _onUnlockFailed(BaseRespBean? resp, + {required bool allowRetry}) async { + if (resp?.code != Code.NOT_ENOUGH_MONEY) return; + await CoinPayBottomSheet.show( + sourcePage: PaySourcePage.dramaPaywall, orderTrack: _orderTrack); + _requestId = null; // 充值后是新的一次解锁,换新键;沿用旧键会拿回刚才那次「余额不足」 + await globalStore.refreshWallet(); + if (!mounted) return; + if (allowRetry && _balance >= (_paywall?.unlockCoin ?? 0)) { + await _unlock(allowRetry: false); // 充值成功,自动补一次解锁 + } + } + + /// 开通短剧卡:和长视频共用同一个会员弹窗,默认选中 ping 下发的 [Config.shortDramaCardId]。 + /// 关掉后不判有没有买,直接交给 onUnlocked 重拉分集详情看服务端放没放行—— + /// _loadEpisode 本来就按 canPlay 决定撤墙还是继续挂着,没买成也只是转一下圈退回付费墙 + Future _onCardTap() async { + if (_busy) return; + _busy = true; + try { + await BuyVipAlert.show( + vipId: Config.shortDramaCardId, orderTrack: _orderTrack); + if (!mounted) return; + setState(() => _unlocking = true); + await widget.onUnlocked(byCard: true).whenComplete(_stopUnlocking); + } finally { + _busy = false; + } + } + + /// 服务端没放行(开卡没买成 / 解锁后仍 canPlay=false)时墙会留着,得把按钮还回来, + /// 否则只剩一个转不完的圈,连重试入口都没有 + void _stopUnlocking() { + if (mounted) setState(() => _unlocking = false); + } + + @override + Widget build(BuildContext context) { + final paywall = _paywall; + return Container( + alignment: Alignment.center, + color: const Color.fromRGBO(0, 7, 18, 0.8), + //已经付过钱了,别再让人对着解锁按钮干等:这层遮罩留着盖住还没建好的播放器 + child: _unlocking + ? const CupertinoActivityIndicator( + color: AppColors.actionRed, radius: 15) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + paywall?.titleUI ?? '更多精彩解锁即享', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600), + ), + 18.sizeBoxH, + EasyRichText( + '剩余金币数:$_coin', + defaultStyle: + const TextStyle(color: Colors.white, fontSize: 14), + patternList: [ + //纯数字,前缀里没有数字所以不会误命中 + EasyRichTextPattern( + targetString: _coin, + matchOption: 'first', + style: const TextStyle(color: AppColors.actionRed), + ), + ], + ), + 18.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _btn( + title: paywall?.coinButtonUI ?? '金币解锁', + colors: const [Color(0xffFFE8BE), Color(0xffE6B764)], + textColor: const Color(0xff694923), + onTap: _onCoinUnlock, + ), + 18.sizeBoxW, + _btn( + title: paywall?.cardButtonUI ?? '开通短剧卡免费看', + colors: const [Color(0xffFF6E6E), Color(0xffFF4D4D)], + textColor: Colors.white, + onTap: _onCardTap, + ), + ], + ), + ], + ), + ); + } + + /// 渐变胶囊按钮(与试看结束遮罩同一套视觉) + Widget _btn({ + required String title, + required List colors, + required Color textColor, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 6), + decoration: BoxDecoration( + gradient: LinearGradient(colors: colors), + borderRadius: BorderRadius.circular(30), + ), + child: Text(title, style: TextStyle(color: textColor, fontSize: 14)), + ), + ); + } +} diff --git a/lib/hj_page/drama/view/drama_speed_sheet.dart b/lib/hj_page/drama/view/drama_speed_sheet.dart new file mode 100644 index 0000000..8929030 --- /dev/null +++ b/lib/hj_page/drama/view/drama_speed_sheet.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +/// 短剧倍速弹窗:选中档主题色 + 右侧对勾,点击直接 Get.back(倍速) +class DramaSpeedSheet extends StatelessWidget { + final double speed; + + /// 倍速档位,设计稿按从快到慢排 + static const speeds = [3.0, 2.0, 1.5, 1.25, 1.0, 0.75]; + + /// 默认倍速,列表里带“(默认)”后缀 + static const normalSpeed = 1.0; + + const DramaSpeedSheet({super.key, this.speed = normalSpeed}); + + static Future show({double speed = normalSpeed}) => + Get.bottomSheet( + DramaSpeedSheet(speed: speed), + backgroundColor: Colors.transparent, // 圆角由内部 Container 画,别让默认底色露出直角 + ); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Container( + width: double.infinity, + //底部 6 + 末档自带的 12 = 设计稿的 18 + padding: + EdgeInsets.fromLTRB(16, 18, 16, 6 + Get.mediaQuery.padding.bottom), + decoration: const BoxDecoration( + color: AppColors.primaryColor, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.stretch, // 分割线要全宽,默认 center 会把它挤成 0 + children: [ + //标题行:左侧收起箭头 + 标题在剩余空间居中 + SizedBox( + height: 24, + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Image.asset('chevron_down.webp'.commonImgPath, + width: 24, height: 24), + ), + Expanded( + child: Text( + '倍速', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 16, + height: 22 / 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + for (final value in speeds) ...[ + if (value != speeds.first) 0.5.line, + _item(value), + ], + ], + ), + ), + ); + } + + //单个倍速档:46 = 文案行高 22 + 上下各 12 的设计稿间距 + Widget _item(double value) { + final isSelected = speed == value; + return InkWell( + enableFeedback: false, + onTap: () => Get.back(result: value), + child: SizedBox( + height: 46, + child: Row( + children: [ + Expanded( + child: Text( + value == normalSpeed ? '${value}x(默认)' : '${value}x', + style: TextStyle( + fontSize: 16, + height: 22 / 16, + color: isSelected + ? AppColors.actionRed + : Colors.white.withValues(alpha: .9), + fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, + ), + ), + ), + if (isSelected) + Image.asset('check.webp'.commonImgPath, width: 24, height: 24), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/drama/view/drama_video_player.dart b/lib/hj_page/drama/view/drama_video_player.dart new file mode 100644 index 0000000..492c483 --- /dev/null +++ b/lib/hj_page/drama/view/drama_video_player.dart @@ -0,0 +1,274 @@ +import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/drama/drama_detail_page.dart'; +import 'package:hgdj/hj_page/drama/view/drama_bottom_bar.dart'; +import 'package:hgdj/hj_page/drama/view/drama_menu.dart'; +import 'package:hgdj/hj_page/drama/view/drama_paywall_view.dart'; +import 'package:hgdj/hj_page/drama/view/drama_video_player_logic.dart'; +import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart'; +import 'package:hgdj/hj_page/short_video/view/video_progress_widget.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:hgdj/tools_base/widget/like_burst_view.dart'; +import 'package:video_player/video_player.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +/// 单集短剧播放器。与短视频播放器分开:短剧不走试看/权益那套, +/// 拦不住就挂付费墙,另有双击点赞、连播倒数、本地续播 +class DramaVideoPlayer extends StatefulWidget { + const DramaVideoPlayer({ + super.key, + required this.index, + required this.videoInfo, + this.isCurrentPage, + this.isScrolling, + this.onCompleted, + this.onRemaining, + this.progressBottom, + this.showEntry = true, + this.speed = 1.0, + this.startSeconds = 0, + this.onCardUnlocked, + }); + + final int index; + final VideoModel? videoInfo; + final bool? isCurrentPage; + final ValueNotifier? isScrolling; // 上下滑动切集时隐藏进度条 + final VoidCallback? onCompleted; // 本条播完 + final ValueChanged? onRemaining; // 距结束 5 秒内的倒数 + final double? progressBottom; // 进度条底部留白覆盖值(二级页要让开底部剧集条) + final bool showEntry; // 是否挂「查看完整短剧」入口;二级页自己有底部剧集条,传 false + final double speed; // 播放倍速,二级页整页共用;Feed 没这个入口,恒为 1.0 + final int startSeconds; // 一级页带进来的起播秒数,只有进二级页落地的那一集非 0 + final Future Function()? onCardUnlocked; // 开卡成功后刷新整部剧,让其他集的角标跟着没 + + @override + State createState() => _DramaVideoPlayerState(); +} + +// PageView 里 2~3 条同时存活,用 per-实例唯一 tag 隔离各自的播放器 logic +class _DramaVideoPlayerState extends State + with UniqueTagMixin { + //进度条压暗档位:滑动中 / 挂着付费墙都用这一档 + static const _dimOpacity = 0.4; + + //压暗/复原的淡入淡出时长。滑动只在 ScrollStart/ScrollEnd 各通知一次, + //硬跳变正好压在手指刚动和刚松的瞬间,很容易被看成闪烁,给一小段过渡盖住 + static const _dimDuration = Duration(milliseconds: 150); + + late final DramaVideoPlayerLogic _logic = DramaVideoPlayerLogic( + index: widget.index, + videoInfo: widget.videoInfo, + //挂不挂「查看完整短剧」入口就是刷剧/连播的分界:Feed 挂,二级页自己有剧集条 + isSerial: !widget.showEntry, + isCurrent: widget.isCurrentPage == true, + speed: widget.speed, + startSeconds: widget.startSeconds, + onCardUnlocked: widget.onCardUnlocked, + //Feed 里这一集播完 = 进入连播,直接把用户带进二级页;二级页自己传 onCompleted 切下一集 + onCompleted: + widget.onCompleted ?? (widget.showEntry ? _onFeedCompleted : null), + onRemaining: widget.onRemaining, + ); + + /// 双击点赞的爱心动效 + final _likeBurst = LikeBurstController(); + + //信息栏要让开下方这一摞,再留设计稿的 18: + //进度条本体 28 + 进度条自己往下让的距离(二级页= 剧集条48 + 底部安全区) + Feed 才有的剧集条 48 + double get _menuBottom => + 28 + (widget.progressBottom ?? 0) + (widget.showEntry ? 48 : 0) + 18; + + @override + void didUpdateWidget(covariant DramaVideoPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + // 同 id 换对象时 State 会被复用,logic 必须跟着换 + _logic.videoInfo = widget.videoInfo; + _logic.index = widget.index; + _logic.isCurrent = widget.isCurrentPage == true; // 落进度只认当前集,见 _saveResume + _logic.setSpeed(widget.speed); // 页面改了倍速,三条活着的都跟上 + // isCurrentPage 由父级每次 rebuild 传入,是流动属性,只能在这里比出「刚滑回本条」 + if (widget.isCurrentPage == true && oldWidget.isCurrentPage != true) { + _logic.onBecomeCurrent(); + } + } + + @override + void dispose() { + _likeBurst.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: _logic, + builder: (logic) { + final ctr = logic.playerCtr; + //首帧是否已就绪:下面封面兜底与播放画面是同一判断的两面,取一次免得两处写反 + final isReady = ctr?.value.isInitialized == true; + return VisibilityDetector( + key: logic.visibilityKey, + onVisibilityChanged: logic.onVisibility, + child: GestureDetector( + onTap: logic.onTapPlay, + //有 onDoubleTap 时单击要等 300ms 才能确认不是双击,这是双击点赞的固有代价 + onDoubleTapDown: (d) => _likeBurst.markAt(d.localPosition), + onDoubleTap: _onDoubleTapLike, + onLongPressUp: logic.resetSpeed, + onLongPressEnd: (_) => logic.resetSpeed(), + onLongPressStart: (_) => logic.speedUp(), + child: Material( + color: Colors.transparent, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + //首帧出来前(初始化中)和挂着付费墙时(播放器压根不建),底下露的都是纯黑, + //铺一层本集封面兜底。条件正好是下面 VideoPlayer 的反面,两者同一帧交接不会重叠; + //付费墙自带 80% 深色底,压上去正好是「封面压暗」的效果 + if (!isReady && logic.video?.cover?.isNotEmpty == true) + NetworkImageLoader( + imageUrl: logic.video?.cover, + borderRadius: 0, + //加载中/失败都留黑,默认那个灰底占位 logo 铺满全屏太难看 + placeHolderWidget: const SizedBox(), + ), + if (isReady) + Center( + child: AspectRatio( + aspectRatio: ctr!.value.aspectRatio, + child: VideoPlayer(ctr), + ), + ), + _bufferLoading(logic), + FullScreenButton(logic: logic), + DramaMenu( + videoModel: logic.video, + playerCtr: ctr, + onSwitchLine: logic.onSwitchLine, + showEntry: widget.showEntry, + bottomInset: _menuBottom, + ), + //付费墙:只有「金币解锁 / 开短剧卡」两个按钮,文案价格全由接口下发 + Positioned.fill( + child: Visibility( + visible: logic.showPaywall, + child: DramaPaywallView( + video: logic.video, onUnlocked: logic.onUnlocked), + ), + ), + LikeBurstView(controller: _likeBurst), + // 进度条 + Feed 里的「查看完整短剧」入口(二级页的底部条由页面自己画) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _progressBar(logic), + if (widget.showEntry) + DramaBottomBar( + leadingIcon: 'full_drama.webp', + text: + '查看完整短剧·全${logic.video?.dramaInfo?.totalEpisode ?? 0}集', + tailIcon: 'chevron_right.webp', + onTap: _openDrama, + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + /// Feed 里这一集播完:只有还停在本条时才跳,别把已经滑走的那条带进来 + void _onFeedCompleted() { + if (widget.isCurrentPage != true || !mounted) return; + _openDrama(startNext: true); + } + + /// 进短剧二级页。[startNext] true = 本集已播完,从下一集接着播 + void _openDrama({bool startNext = false}) { + _logic.playerCtr?.pause(); + //Feed 里放的是第几集就从第几集接上,下标越界由二级页钳住 + final index = (widget.videoInfo?.episodeNo ?? 1) - 1 + (startNext ? 1 : 0); + //这一刻的秒数直接透给连播模式,不写进续播记录——刷剧模式只留「看过」不动进度(DramaMenu._openDrama 同此)。 + //本集已播完时从下一集的 0 秒起 + Get.to( + () => DramaDetailPage( + drama: widget.videoInfo?.dramaInfo, + initialIndex: index < 0 ? 0 : index, + initialSeconds: + startNext ? 0 : (_logic.playerCtr?.value.position.inSeconds ?? 0), + ), + ); + } + + /// 双击点赞:先弹爱心给反馈,再去调接口(已点赞的只弹爱心不重复请求) + void _onDoubleTapLike() { + _likeBurst.burst(); + _logic.likeOnce(); + } + + /// 内嵌进度条。压暗态只有一档 40%,用**一个** AnimatedOpacity 表达:上下滑动切集时 + /// (不做全透明,留个淡影不至于整条凭空消失)、挂着付费墙时都压到 40%,带 150ms 淡入淡出。 + /// 嵌套两层 Opacity 会相乘,别拆开写 + Widget _progressBar(DramaVideoPlayerLogic logic) { + final (minSeek, maxSeek) = logic.previewRange; + final Widget bar = VideoProgressWidget( + controller: logic.playerCtr, + padding: EdgeInsets.only( + left: 12, right: 12, bottom: widget.progressBottom ?? 0), + //试看放的是完整视频,可拖区间夹在 [previewStart, previewStart+previewSeconds];挂墙后整条禁拖。 + //与长视频页同一套(VideoMenuView 的 minSeek/maxSeek + enableSeek) + minSeekDuration: minSeek, + maxSeekDuration: maxSeek, + enableSeek: !logic.showPaywall, + ); + final scrolling = widget.isScrolling; + if (scrolling == null) + return Opacity(opacity: logic.showPaywall ? _dimOpacity : 1, child: bar); + return ValueListenableBuilder( + valueListenable: scrolling, + //只做透明+屏蔽点击,不能用 Visibility——它会卸载 child,重建时 initState 会拿旧 controller 挂监听。 + //bar 走 child 传进来,滑动时只重建 IgnorePointer/AnimatedOpacity 这两层,进度条本身不重建 + builder: (_, isScroll, child) => IgnorePointer( + ignoring: isScroll, + child: AnimatedOpacity( + opacity: isScroll || logic.showPaywall ? _dimOpacity : 1, + duration: _dimDuration, + child: child!, + ), + ), + child: bar, + ); + } + + /// 未就绪/缓冲中的居中转圈。挂着付费墙时不转——那不是在加载,是没权限 + Widget _bufferLoading(DramaVideoPlayerLogic logic) { + if (logic.showPaywall) return const SizedBox(); + const indicator = Center( + child: + CupertinoActivityIndicator(color: AppColors.actionRed, radius: 15)); + final ctr = logic.playerCtr; + if (ctr == null) return indicator; // 控制器还没建好,同样转圈 + return ValueListenableBuilder( + valueListenable: ctr, + builder: (_, value, __) => !value.isInitialized || value.isBuffering + ? indicator + : const SizedBox(), + ); + } +} diff --git a/lib/hj_page/drama/view/drama_video_player_logic.dart b/lib/hj_page/drama/view/drama_video_player_logic.dart new file mode 100644 index 0000000..a449a81 --- /dev/null +++ b/lib/hj_page/drama/view/drama_video_player_logic.dart @@ -0,0 +1,381 @@ +import 'package:flutter/widgets.dart'; +import 'package:hgdj/hj_model/media_content.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/tools_base/cache/history/drama_resume_store.dart'; +import 'package:hgdj/hj_page/short_video/view/video_player_base_logic.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/api_service/drama_service.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +/// 单集短剧的播放逻辑,PageView 每项一个实例。 +/// 能不能播只认服务端 canPlay,拦不住就挂付费墙;续播位置纯本地记 +class DramaVideoPlayerLogic extends VideoPlayerBaseLogic + with WidgetsBindingObserver { + /// 开卡放行广播:PageView 预建的上下两条已各自问过 canPlay,不叫醒就还挂着买卡前那道墙 + static final ValueNotifier unlockRevision = ValueNotifier(0); + + static void broadcastUnlock() => unlockRevision.value++; + + DramaVideoPlayerLogic({ + required this.index, + required this.videoInfo, + required this.isSerial, + required this.isCurrent, + this.speed = 1.0, + this.startSeconds = 0, + this.onCompleted, + this.onRemaining, + this.onCardUnlocked, + }); + + /// 由页面同步刷新:PageView 复用 State 时不跟着换就会一直读旧 model + int index; + VideoModel? videoInfo; + + /// 是不是连播模式(二级页)。续播记录只在连播模式读写—— + /// 刷剧模式每划过一部剧就写一条,会把真正在追的剧挤出淘汰线 + final bool isSerial; + + /// 是不是 PageView 停在的那一集,由页面在 didUpdateWidget 里同步。 + /// 落进度只认它:二级页同时活着三条,邻居那两条停在 0 秒会把在看的盖掉。 + /// 不能换成 isVisible——进全屏/被弹窗盖住都算不可见,但在播的仍是这一集 + bool isCurrent; + + /// 播放倍速,由二级页整页统一给。控制器每集新建,起播时要补上 + double speed; + + /// 一级页带进来的起播秒数(刷剧模式不写库,位置只能靠参数递)。用完清零,滑走再回来从头看 + int startSeconds; + + /// 本条播完的回调,每次播放只回调一次 + final VoidCallback? onCompleted; + + /// 距本条结束还剩几秒(只在最后 5 秒内回调,二级页据此提示「Ns后播放下一集」) + final ValueChanged? onRemaining; + int _lastRemaining = -1; + + /// 开卡成功后由页面刷新整部剧:开卡是整部放行,其他集的角标也得跟着没 + final Future Function()? onCardUnlocked; + + bool showPaywall = false; // canPlay=false 时挂付费墙 + bool _loading = false; // _loadEpisode 进行中,挡住重入 + bool _reloadPending = false; // 加载中又来了放行信号,等这次跑完补一次,别丢 + + /// 正在放试看片。地址、到点收尾、滑回重看都跟正片不一样 + bool _isPreview = false; + bool _liking = false; // 点赞请求中,防连点 + DateTime? _savedAt; // 上次落进度的时刻,节流用 + + /// 起播位置是否已定好。initPlayer 里 addListener 排在续播 seek 之前, + /// 初始化期间的任何 value 变化都会触发 onTick(0),不挡就把要续的位置抹了 + bool _resumeDone = false; + + @override + VideoModel? get video => videoInfo; + + @override + bool get isBlocked => showPaywall; + + @override + String get debugTag => 'drama $index'; + + String? get _mediaId => videoInfo?.dramaInfo?.id; + + @override + void onInit() { + super.onInit(); + //只有连播模式要盯挂起:从后台被杀不会再走 onClose,这一集看到哪就丢了 + if (isSerial) WidgetsBinding.instance.addObserver(this); + unlockRevision.addListener(_onUnlockBroadcast); + globalStore.dramaCardGained.addListener(_onUnlockBroadcast); + _loadEpisode(); // 地址是现签的、canPlay 只认服务端,拿到详情再决定起播还是挂墙 + } + + @override + void onClose() { + if (isSerial) WidgetsBinding.instance.removeObserver(this); + unlockRevision.removeListener(_onUnlockBroadcast); + globalStore.dramaCardGained.removeListener(_onUnlockBroadcast); + _saveResume(); + super.onClose(); + } + + /// app 挂起/切后台:补一次进度 + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive || + state == AppLifecycleState.detached) { + _saveResume(); + } + } + + @override + void onPauseAll() { + _saveResume(); + super.onPauseAll(); + } + + /// 竖滑回到本条:进度归零重看 + void onBecomeCurrent() { + resetCompleted(); + _lastRemaining = -1; + _savedAt = null; // 从头重看,下一个 tick 就落 0,别被上一轮节流挡住 + //只撤试看到点那道墙;没配试看的墙撤了会去播一个空地址 + if (_isPreview && showPaywall) { + showPaywall = false; + notify(); + } + playerCtr?.seekTo( + Duration(seconds: _isPreview ? _previewStart : 0)); //试看重看要回到起播点,不是 0 + syncPlay(); + _markWatched(); // 滑到这条才算看过:onInit 预建时 isCurrent 还是 false + } + + /// 刷剧模式的观看历史:只刷观看时间(列表按它倒序),进度沿用旧记录不覆盖二级页的续播位置。 + /// 挂着墙 = 一秒没看成,不记;预建的邻居也不记 + void _markWatched() { + if (isSerial || showPaywall || !isCurrent) return; + dramaResume.markWatched( + mediaId: _mediaId, + contentId: videoInfo?.dramaEpisode?.id, + drama: videoInfo?.dramaInfo, // 历史记录页要拿它画卡片 + ); + } + + /// 切回短剧 tab / 被别的路由让开后又回到本条:还挂着墙就重拉一次钱包。 + /// 墙上的余额是进墙那一刻的快照,去「我的」充完金币回来没人通知它, + /// 不对一次的话充过钱的人还是会被当成余额不足 + @override + void onVisibility(VisibilityInfo info) { + final wasVisible = isVisible; + super.onVisibility(info); + if (!wasVisible && isVisible && showPaywall) globalStore.refreshWallet(); + } + + /// 别处开卡放行 / 刷用户信息刷出了短剧权益:这一集只要还没放行就重问一次服务端。 + /// 不能只看 showPaywall——试看还在放的时候墙没挂起来,信号会被丢掉, + /// 等试看放完照样挂一道买卡前的墙(后台加卡、在「我的」下拉刷新后进短剧就是这个时序) + void _onUnlockBroadcast() { + if (isClosed || videoInfo?.dramaEpisode?.canPlay == true) return; + _loadEpisode(); + } + + int get _previewStart => videoInfo?.dramaEpisode?.previewStart ?? 0; + + //试看看到第几秒为止:起播点 + 时长,previewSeconds 是时长不是绝对位置 + int get _previewEnd => + _previewStart + (videoInfo?.dramaEpisode?.previewSeconds ?? 0); + + /// 试看可拖区间 [起, 止],不在试看态时 (null, null)。同长视频页 VideoMenuView._calcSeekRange + (Duration?, Duration?) get previewRange => _isPreview + ? (Duration(seconds: _previewStart), Duration(seconds: _previewEnd)) + : (null, null); + + /// 没解锁 + 开关开 + 有地址 + 有时长,刷剧和连播两个页面一视同仁。 + /// 秒数必须 >0:试看地址给的是完整视频、后端不裁片,配成 0 就是整集免费送 + bool _canPreview(MediaContent? episode) => + episode?.canPlay != true && + episode?.previewEnabled == true && + (episode?.previewSeconds ?? 0) > 0 && + episode?.previewVideoUrl?.isNotEmpty == true; + + /// 拉分集详情并决定起播/挂墙。重入会开出两个播放器控制器:两次都拿同一个旧的去 dispose + /// (双重释放),后建的那个还会把先建的顶掉、没人释放。买卡那一下 onUnlocked 和 + /// 「刷出短剧权益」的广播是前后脚到的,不挡就会撞上 + Future _loadEpisode() async { + //加载中又来一个信号:记下来跑完再补一次,不能直接丢——在飞的这次是信号到达前发出的, + //拿回来的可能还是放行前那份 + if (_loading) { + _reloadPending = true; + return; + } + _loading = true; + try { + await _loadEpisodeInner(); + } finally { + _loading = false; + } + if (_reloadPending && !isClosed) { + _reloadPending = false; + await _loadEpisode(); + } + } + + /// 拉分集详情:能播就起播并接上续播位置;不能播的,配了试看就先放试看片,其余挂付费墙 + Future _loadEpisodeInner() async { + final target = videoInfo; + final prev = target?.dramaEpisode; + final episode = await DramaService.fetchEpisode(prev?.id); + if (isClosed) return; + if (episode == null) { + showToast("剧集加载失败"); + //按最后已知的 canPlay 归位,不能顺手撤墙——撤了只剩空转圈,连重试入口都没有 + showPaywall = prev?.canPlay != true; + notify(); + return; + } + //兜底沿用 Feed 那份;两个地址判空串不判 null——服务端没有时给的是 "" + episode.previewEnabled ??= prev?.previewEnabled; + episode.previewStart ??= prev?.previewStart; + episode.previewSeconds ??= prev?.previewSeconds; + if (episode.previewVideoUrl?.isNotEmpty != true) + episode.previewVideoUrl = prev?.previewVideoUrl; + if (episode.previewH265Url?.isNotEmpty != true) + episode.previewH265Url = prev?.previewH265Url; + final usePreview = _canPreview(episode); + target + ?..dramaEpisode = episode + //试看只换地址,265 选流/解码回退/切线路那一整套照旧走 + ..sourceURL = usePreview ? episode.previewVideoUrl : episode.videoUrl + ..h265Url = usePreview ? episode.previewH265Url : episode.h265Url + ..playTime = episode.playTime + ..freeArea = episode.isFree == true; + // 回写选集列表里的那一条,解锁后再开选集面板不会还挂着锁 + final list = target?.dramaInfo?.episodeList; + final index = list?.indexWhere((e) => e.id == episode.id) ?? -1; + if (index >= 0) list![index] = episode; + if (episode.canPlay != true && !usePreview) { + showPaywall = true; + notify(); + return; + } + _isPreview = usePreview; + //墙留着当遮罩盖住建播放器这段,撤早了只剩封面压一个大转圈。 + //必须传 target 不能传 video:新地址写回的是 target,videoInfo 可能已被 didUpdateWidget 换成另一个对象 + await initPlayer(target); + _applySpeed(); // 新控制器默认 1 倍,把页面选的档位补上 + //续播只在连播模式定位:刷剧模式不写库,正片一律从 0 播。一级页带进来的位置优先—— + //从刷剧页点进来要接着那边的秒数往下放,试看片同样接着放。 + //试看不认本地续播记录(记的是正片进度,一跳就越过试看区间),没带位置就从后端给的 previewStart 起 + final last = isSerial && !usePreview ? dramaResume.of(_mediaId) : null; + final resume = startSeconds > 0 + ? startSeconds + : (last?.contentId == episode.id ? last!.progressSeconds : 0); + final seconds = usePreview && resume <= 0 ? _previewStart : resume; + startSeconds = 0; + if (seconds > 0 && !isClosed) + await playerCtr?.seekTo(Duration(seconds: seconds)); + if (isSerial) _resumeDone = !usePreview; // 试看那段不落进度,正片才从这一刻起作数 + if (isClosed) return; + //位置定好才撤墙,中间不露转圈。播放器建失败也照撤—— + //已经付过钱的人不该再看见付费墙,对着转圈重试也比诱导二次付费强 + showPaywall = false; + notify(); + syncPlay(); // initPlayer 里那次被 isBlocked 挡掉了,撤墙后补一次 + if (!isSerial) _markWatched(); + } + + /// 页面改了倍速:已就绪的立刻跟上,还没建好的等 _loadEpisode 起播时补 + void setSpeed(double value) { + if (speed == value) return; + speed = value; + _applySpeed(); + } + + void _applySpeed() { + if (playerCtr?.value.isInitialized == true) + playerCtr?.setPlaybackSpeed(speed); + } + + /// 长按 2 倍抬手后回到用户选的档位,不是基类写死的 1.0 + @override + void resetSpeed() => _applySpeed(); + + /// 解锁成功(金币 / 开短剧卡):重拉详情,服务端放行就接着播。 + /// 全程静默,墙由 _loadEpisode 在播放器就绪那一刻撤——提前撤会先空转一圈 + Future onUnlocked({bool byCard = false}) async { + await _loadEpisode(); + //开卡是第三方支付,弹窗关了不等于到账:墙还挂着就是没放行,别去清别的集的角标 + if (isClosed || !byCard || showPaywall) return; + //广播挂在这儿而不是页面回调里:刷剧页没传 onCardUnlocked,挂那儿的话它的邻居永远收不到 + broadcastUnlock(); + await onCardUnlocked?.call(); + } + + /// 双击点赞,已点赞不重复调接口。点赞挂在「剧」上,不是当前这一集 + Future likeOnce() async { + final model = video; + if (model == null || _liking) return; + if (model.vidStatus?.hasLiked == true) return; + _liking = true; + final ok = await CommonService.sendLike(_mediaId, 'drama'); + _liking = false; + if (!ok || isClosed) return; + model.vidStatus?.hasLiked = true; + model.likeCount = (model.likeCount ?? 0) + 1; + notify(); + } + + /// 每 500ms 一次:落一次进度(节流 10 秒) + 距结束 5 秒内让底部条倒数。 + /// 连播自动切集,中间没有退出/暂停这类一次性时机,只能靠 tick 落 + @override + void onTick(int position) { + //试看到点就收:试看不落续播、也不提示「Ns后播下一集」(进得了试看态就一定有正数秒数) + if (_isPreview) { + if (position >= _previewEnd) _endPreview(); + return; + } + _saveResume(seconds: position, throttle: true); + final duration = playerCtr?.value.duration.inSeconds ?? 0; + if (duration <= 0) return; + final remaining = duration - position; + if (remaining <= 0 || remaining > 5) { + _lastRemaining = -1; + return; + } + if (remaining == _lastRemaining) return; + _lastRemaining = remaining; + onRemaining?.call(remaining); + } + + /// 本集播完:记成从头,切到下一集后由下一集的第一次 onTick 覆盖掉 + @override + void onFinished() { + //试看片放完 = 试看到点,当成整集看完会把用户直接带进二级页 + if (_isPreview) { + _endPreview(); + return; + } + _saveResume(seconds: 0); + onCompleted?.call(); + } + + /// 试看结束:停在最后一帧再盖墙。墙一挂 isBlocked 就为真,syncPlay 不会把它重新播起来 + void _endPreview() { + if (showPaywall) return; + playerCtr?.pause(); + showPaywall = true; + notify(); + } + + /// 点屏播放/暂停,暂停时落一次进度。 + /// 挂着墙一律不动播放器:试看到点后墙背后留着个暂停好的控制器,点一下就能接着放完 + void onTapPlay() { + if (showPaywall) return; + if (togglePlay()) _saveResume(); + } + + /// 记一次续播位置(纯本地)。三道闸见 [isSerial] / [isCurrent] / [_resumeDone] 的字段注释。 + /// [throttle] 只给播放中那条用;节流字段是实例级的,换集必然先落一条。 + /// [seconds] 不传取播放器当前位置——播放器起不来时位置恒为 0,写下去会抹平上次的记录,这种一条不记 + void _saveResume({int? seconds, bool throttle = false}) { + if (!isSerial || !isCurrent || !_resumeDone) return; + final ctr = playerCtr; + if (seconds == null && ctr?.value.isInitialized != true) return; + final now = DateTime.now(); + if (throttle && + _savedAt != null && + now.difference(_savedAt!).inSeconds < 10) return; + _savedAt = now; + dramaResume.record( + mediaId: _mediaId, + contentId: videoInfo?.dramaEpisode?.id, + seconds: seconds ?? ctr!.value.position.inSeconds, + drama: videoInfo?.dramaInfo, // 历史记录页要拿它画卡片 + ); + } +} diff --git a/lib/hj_page/find/acg_game_detail_logic.dart b/lib/hj_page/find/acg_game_detail_logic.dart new file mode 100644 index 0000000..85a2ecb --- /dev/null +++ b/lib/hj_page/find/acg_game_detail_logic.dart @@ -0,0 +1,117 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/hj_utils/history_util.dart'; +import 'package:hgdj/hj_utils/const.dart'; + +import '../../alert/video/video_buy_bottom_sheet.dart'; + +class AcgGameDetailLogic extends GetxController + with GetTickerProviderStateMixin { + // 外部传入:游戏(资源) ID + final String gameId; + AcgGameDetailLogic({this.gameId = ''}); + + // Tab:相关推荐 / 评论 + final List tabs = ['相关推荐', '评论']; + late TabController tabCtr = TabController(length: tabs.length, vsync: this); + + // 详情数据 + 评论总数(评论区回调实时刷新角标) + VideoModel? detailInfo; + RxInt totalCtCount = 0.obs; + + @override + void onReady() { + super.onReady(); + loadData(); + } + + @override + void onClose() { + tabCtr.dispose(); // 释放 TabController,避免 ticker 泄漏 + super.onClose(); + } + + loadData() async { + detailInfo = await VidService.getDetail(gameId); + if (detailInfo != null) { + totalCtCount.value = detailInfo?.commentCount ?? 0; + HistoryUtil.insert(detailInfo!, MediaStyle.Game); + } + detailInfo ??= VideoModel(); + update(); + } + + ////金币解锁 + showAlert() async { + if (detailInfo?.lockType == 1) { + var ret = await Get.bottomSheet(VideoBuyBottomSheet(video: detailInfo!)); + if (ret == true) { + detailInfo?.vidStatus?.hasPaid = true; + update(['link']); + Clipboard.setData(ClipboardData(text: detailInfo?.seedLinkUrl ?? "")); + showToast("链接地址复制成功"); + } + } + } + + onLinkClickAction() { + Clipboard.setData(ClipboardData(text: detailInfo?.seedLinkUrl ?? "")); + showToast("链接地址复制成功"); + } + + //收藏 + onCollectionAction() async { + String type = 'SEED_LINK'; + String? objID = detailInfo?.id; + bool hasCollected = detailInfo?.vidStatus?.hasCollected ?? false; + + if (hasCollected) { + final success = await MineService.postCollect(objID, type, false); + if (success) { + detailInfo?.collectCount = (detailInfo?.collectCount ?? 1) - 1; + detailInfo?.vidStatus?.hasCollected = false; + detailInfo?.collectCount = max(0, detailInfo?.collectCount ?? 0); + showToast("已取消收藏"); + } + } else { + final success = await MineService.postCollect(objID, type, true); + if (success) { + detailInfo?.collectCount = (detailInfo?.collectCount ?? 0) + 1; + detailInfo?.vidStatus?.hasCollected = true; + detailInfo?.collectCount = max(0, detailInfo?.collectCount ?? 0); + showToast("收藏成功"); + } + } + update(['collect']); + } + + //点赞 + onLikeAction() async { + String type = 'SEED_LINK'; + String? objID = detailInfo?.id; + bool hasLiked = detailInfo?.vidStatus?.hasLiked ?? false; + + if (hasLiked) { + await CommonService.cancelLike(objID, type); + detailInfo?.likeCount = (detailInfo?.likeCount ?? 1) - 1; + detailInfo?.likeCount = max(0, detailInfo?.likeCount ?? 0); + detailInfo?.vidStatus?.hasLiked = false; + showToast("取消点赞"); + } else { + await CommonService.sendLike(objID, type); + detailInfo?.likeCount = (detailInfo?.likeCount ?? 0) + 1; + detailInfo?.likeCount = max(0, detailInfo?.likeCount ?? 0); + detailInfo?.vidStatus?.hasLiked = true; + showToast("点赞成功"); + } + update(['like']); + } +} diff --git a/lib/hj_page/find/acg_game_detail_page.dart b/lib/hj_page/find/acg_game_detail_page.dart new file mode 100644 index 0000000..50002f6 --- /dev/null +++ b/lib/hj_page/find/acg_game_detail_page.dart @@ -0,0 +1,393 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/comment/comment_views.dart'; +import 'package:hgdj/hj_page/find/acg_game_detail_logic.dart'; +import 'package:hgdj/hj_page/find/acg_game_recommend_page.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/hj_utils/sliver_delegate.dart'; +import 'package:hgdj/tools_base/banner/ads_banner_widget.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; +import 'package:mobkit_dashed_border/mobkit_dashed_border.dart'; + +import '../../hj_utils/widget_util.dart'; + +//acg游戏详情 +class AcgGameDetailPage extends StatefulWidget { + final String id; + + const AcgGameDetailPage({super.key, this.id = ''}); + + @override + State createState() => _AcgGameDetailPageState(); +} + +class _AcgGameDetailPageState extends State + with UniqueTagMixin { + // 详情可自我堆叠(相关推荐→再开详情),uniqueTag 由 UniqueTagMixin 提供,隔离多实例 logic + late final AcgGameDetailLogic logic = AcgGameDetailLogic(gameId: widget.id); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: logic, + tag: uniqueTag, + builder: (_) { + return Scaffold( + appBar: AppBar(title: Text(_.detailInfo?.title ?? '')), + body: () { + if (_.detailInfo == null) return LoadingCenterWidget(); + if (_.detailInfo?.id == null) return CErrorWidget(); + return _buildSourceContent(); + }(), + ); + }, + ); + } + + Widget _buildSourceContent() { + return ExtendedNestedScrollView( + onlyOneScrollInBody: true, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + //图片 + if (logic.detailInfo?.seriesCover?.isNotEmpty == true) + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: ImageCollectionWidget( + imgs: logic.detailInfo?.seriesCover, + showCIndicator: true, + showIndex: false, + aspectRatio: 343 / 143, + ), + ), + ), + _buildContent(), + SliverPersistentHeader( + pinned: true, + delegate: MySliverDelegate( + maxHeight: 44, + minHeight: 44, + child: Container( + color: AppColors.primaryColor, + child: _buildSection(), + ), + ), + ), + ]; + }, + body: TabBarView( + controller: logic.tabCtr, + children: [ + AcgGameRecommendPage(mediaModel: logic.detailInfo).keepAlive, + CommentView( + logic.gameId, + objType: "video", + totalCallback: (value) { + logic.totalCtCount.value = value; + }, + ).keepAlive, + ], + ), + ); + } + + //推荐和评论 + Widget _buildSection() { + return TabBar( + padding: EdgeInsets.only(bottom: 6), + tabAlignment: TabAlignment.start, + controller: logic.tabCtr, + isScrollable: true, + labelColor: Colors.white, + labelStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + unselectedLabelColor: Color(0xffE0E0E0), + unselectedLabelStyle: + const TextStyle(fontSize: 16, fontWeight: FontWeight.w400), + indicator: CustomIndicator( + width: 16, + height: 4, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(3), + topRight: Radius.circular(3), + bottomLeft: Radius.circular(0), + bottomRight: Radius.circular(0), + ), + ), + tabs: List.generate( + logic.tabs.length, + (index) => Container( + padding: EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(logic.tabs[index]), + if (index == 1) ...[ + Obx( + () => Container( + padding: EdgeInsets.only(left: 2), + child: Text( + '${logic.totalCtCount.value == 0 ? '' : logic.totalCtCount.value}', + style: + textStyle(12, AppColors.actionRed, FontWeight.w500), + ), + ), + ), + ] + ], + ), + ), + ), + ); + } + + Widget _buildContent() { + return SliverToBoxAdapter( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + 10.sizeBoxH, + Text( + '游戏简介:', + style: const TextStyle( + fontSize: 16, + color: Color(0xffDCDCDC), + fontWeight: FontWeight.w500, + ), + ), + 18.sizeBoxH, + Text( + logic.detailInfo?.content ?? '', + style: const TextStyle( + fontSize: 14, + color: Color(0xffDCDCDC), + fontWeight: FontWeight.w400, + ), + ), + 18.sizeBoxH, + _buildRightWidget(), + if (logic.detailInfo?.tags != null && + logic.detailInfo?.tags?.isNotEmpty == true) ...[ + 20.sizeBoxH, + Wrap( + spacing: 10, + runSpacing: 0, + children: + List.generate(logic.detailInfo?.tags?.length ?? 0, (index) { + final tag = logic.detailInfo?.tags?[index]; + return Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(3), + ), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + '#${tag?.name ?? ''}', + style: TextStyle( + fontSize: 12, + color: Color(0xff989898), + ), + ), + ); + }), + ), + ], + 20.sizeBoxH, + _buildInfoStatus(), + 18.sizeBoxH, + 0.5.line, + 18.sizeBoxH, + ], + ), + ), + ); + } + + Widget _buildRightWidget() { + return GetBuilder( + tag: uniqueTag, + id: 'link', + builder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_.detailInfo?.lockType == 0) ...[ + //无需解锁 + Row( + children: [ + Spacer(), + InkWell( + enableFeedback: false, + onTap: () => logic.onLinkClickAction(), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + border: DashedBorder.fromBorderSide( + dashLength: 3, + side: BorderSide( + color: Colors.white.withValues(alpha: .4), + width: 1, + ), + ), + borderRadius: BorderRadius.circular(32), + ), + child: EasyRichText( + '点击复制「种子资源」', + defaultStyle: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w400), + patternList: [ + EasyRichTextPattern( + targetString: '「种子资源」', + style: TextStyle(color: Color(0xffE1351F)), + ), + ], + ), + ), + ), + Spacer(), + ], + ) + ] else ...[ + //金币解锁 + InkWell( + enableFeedback: false, + onTap: () => logic.showAlert(), + child: Container( + height: 124, + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + border: DashedBorder.fromBorderSide( + dashLength: 3, + side: BorderSide( + color: Color(0xffC781FF), + width: 1, + ), + ), + borderRadius: BorderRadius.circular(8), + ), + child: Container( + alignment: Alignment.center, + width: double.infinity, + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.white.withValues(alpha: .1), + ), + child: Text( + '内容已隐藏,解锁后查看', + style: textStyle(14, Colors.white, FontWeight.w400), + ), + ), + ), + ), + 18.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () => logic.showAlert(), + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed, + ), + child: Text( + '${_.detailInfo?.newsType == "SEED_LINK" ? _.detailInfo?.originCoins ?? 0 : _.detailInfo?.coins ?? 0}金币/立即解锁', + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ) + ] + ], + ), + ); + } + + //观看/点赞/解锁 + Widget _buildInfoStatus() { + return Row( + children: [ + //观看 + _buildStatusItem('eye_white.webp'.commonImgPath, + logic.detailInfo?.playCount.countStr ?? '', false), + 30.sizeBoxW, + //收藏 + GetBuilder( + tag: uniqueTag, + id: 'collect', + builder: (_) => InkWell( + enableFeedback: false, + onTap: logic.onCollectionAction, + child: _buildStatusItem( + logic.detailInfo?.hasCollected == true + ? 'collect_red.png'.commonImgPath + : 'collect_path.png'.commonImgPath, + logic.detailInfo?.collectCount?.countOr("0") ?? '', + logic.detailInfo?.hasCollected == true, + ), + ), + ), + 30.sizeBoxW, + //喜欢 + GetBuilder( + tag: uniqueTag, + id: 'like', + builder: (_) => InkWell( + enableFeedback: false, + onTap: logic.onLikeAction, + child: _buildStatusItem( + logic.detailInfo?.hasLiked == true + ? 'like_red.png'.commonImgPath + : 'like_empty.png'.communityPath, + logic.detailInfo?.likeCount?.countOr("0") ?? '', + logic.detailInfo?.hasLiked == true, + ), + ), + ), + 30.sizeBoxW, + _buildStatusItem( + 'locked_white.png'.communityPath, + logic.detailInfo?.purchaseCount.countStr ?? '', + false, + ), + ], + ); + } + + Widget _buildStatusItem(String imgPath, String count, bool isSelected) { + return Row( + children: [ + Image.asset( + imgPath, + width: 20, + color: isSelected ? null : Color(0xff989898), + ), + 2.sizeBoxW, + Text( + count, + style: TextStyle( + fontSize: 12, + color: isSelected ? AppColors.actionRed : Color(0xff989898), + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/find/acg_game_recommend_logic.dart b/lib/hj_page/find/acg_game_recommend_logic.dart new file mode 100644 index 0000000..5c3e093 --- /dev/null +++ b/lib/hj_page/find/acg_game_recommend_logic.dart @@ -0,0 +1,35 @@ +import 'package:hgdj/hj_model/home/video_list_model.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +class AcgGameRecommendLogic extends ListBaseLogic { + VideoModel? mediaModel; + + AcgGameRecommendLogic({this.mediaModel}); + + @override + onReady() { + super.onReady(); + loadData(); + } + + loadData({int pageNum = 1, bool showLoading = false, int size = 10}) async { + dataList ??= []; + VideoListResp? resp = await CommonService.getRecommendList( + pageNumber: pageNum, pageSize: 10, tagId: mediaModel?.tags?.first.id); + + if (resp != null) { + currentPage = pageNum; + if (pageNum == 1) dataList?.clear(); + dataList?.addAll(resp.videos ?? []); + } + update(); + resp?.hasNext == true + ? refreshCtr?.loadComplete() + : refreshCtr?.loadNoData(); + refreshCtr?.refreshCompleted(); + } + + loadMoreData() async => loadData(pageNum: currentPage + 1); +} diff --git a/lib/hj_page/find/acg_game_recommend_page.dart b/lib/hj_page/find/acg_game_recommend_page.dart new file mode 100644 index 0000000..d88cd7f --- /dev/null +++ b/lib/hj_page/find/acg_game_recommend_page.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/find/acg_game_recommend_logic.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +import '../home/home_cell_style/video_simple_cell.dart'; + +//游戏推荐页面 +class AcgGameRecommendPage extends StatefulWidget { + final VideoModel? mediaModel; + const AcgGameRecommendPage({super.key, this.mediaModel}); + + @override + State createState() => _AcgGameRecommendPageState(); +} + +class _AcgGameRecommendPageState extends State + with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: AcgGameRecommendLogic(mediaModel: widget.mediaModel), + builder: (_) { + if (_.dataList == null) return LoadingCenterWidget(); + if (_.dataList?.isEmpty == true) + return CErrorWidget( + errorMsg: '暂无数据', + retryOnTap: () => _.loadData(), + ); + return pullYsRefresh( + onInit: (ctr) => _.refreshCtr = ctr, + onLoading: (refreshcontroller) => _.loadMoreData(), + enablePullDown: false, + child: GridView.builder( + physics: ClampingScrollPhysics(), + padding: EdgeInsets.fromLTRB(16, 12, 16, 40), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 5, + childAspectRatio: 179 / 147, + ), + itemCount: _.dataList?.length ?? 0, + itemBuilder: (context, index) { + VideoModel? model = _.dataList?[index]; + return VideoSimpleCell( + videoModel: model, + isFromHY: true, + textLines: 1, + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/hj_page/find/pic_collect/album_action_widget.dart b/lib/hj_page/find/pic_collect/album_action_widget.dart new file mode 100644 index 0000000..122b18f --- /dev/null +++ b/lib/hj_page/find/pic_collect/album_action_widget.dart @@ -0,0 +1,188 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/alert/video/share_media_dialog.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/comment/comment_alert.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:like_button/like_button.dart'; + +///图集底部操作按钮组 +class AlbumActionWidget extends StatefulWidget { + final VideoModel? videoModel; + final int currentIndex; //如果传-1,则不显示查看 '1/3'之类的数据 + + const AlbumActionWidget({super.key, this.videoModel, this.currentIndex = 0}); + + @override + State createState() => _AlbumActionWidgetState(); +} + +class _AlbumActionWidgetState extends State { + VideoModel? get model => widget.videoModel; + + bool get showIndex => widget.currentIndex != -1; + + @override + Widget build(BuildContext context) { + return Container( + height: 70, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: showIndex ? 9 : 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _likeBtn(), + _actionItem( + 'count_msg.png'.communityPath, + model?.commentCount.countOr('评论') ?? '评论', + onTap: () => showCommentDialog(model?.id ?? ''), + ), + _collectBtn(), + _actionItem( + 'share_grey.png'.commonImgPath, + '分享', + onTap: () => Get.dialog(ShareMediaDialog(videoModel: model), + useSafeArea: false), + ), + //页码 1/3 + if (showIndex) + Container( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 2), + decoration: BoxDecoration( + color: Colors.white, borderRadius: BorderRadius.circular(20)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('${widget.currentIndex + 1}', + style: const TextStyle( + color: AppColors.actionRed, fontSize: 12)), + Text('/${model?.seriesCover?.length ?? 0}', + style: const TextStyle( + color: Color(0xff3D3D3D), fontSize: 12)), + ], + ), + ), + ], + ), + ); + } + + //点赞 + Widget _likeBtn() { + return LikeButton( + size: 20, + isLiked: model?.vidStatus?.hasLiked ?? false, + circleColor: + CircleColor(start: AppColors.actionRed, end: AppColors.actionRed), + bubblesColor: BubblesColor( + dotPrimaryColor: AppColors.actionRed, + dotSecondaryColor: AppColors.actionRed, + ), + likeBuilder: (isLiked) => Image.asset( + isLiked + ? 'like_red.png'.commonImgPath + : 'video_like_border.png'.videoPath, + width: 20, + height: 20, + color: isLiked ? AppColors.actionRed : const Color(0xff989898), + ), + likeCount: model?.likeCount ?? 0, + countBuilder: (count, isLiked, text) => Text( + count.countOr('点赞'), + style: TextStyle( + color: isLiked ? AppColors.actionRed : const Color(0xff989898), + fontSize: 12), + ), + onTap: (isLiked) async { + final hasLiked = model?.vidStatus?.hasLiked ?? false; + final count = model?.likeCount ?? 0; + bool isOk = false; + try { + isOk = hasLiked + ? await CommonService.cancelLike(model?.id, 'PIC') + : await CommonService.sendLike(model?.id, 'PIC'); + } catch (e) { + debugLog('album like $e'); + } + //失败不动本地状态,返回原值让按钮回弹 + if (!isOk) return hasLiked; + model?.likeCount = max(0, count + (hasLiked ? -1 : 1)); + model?.vidStatus?.hasLiked = !hasLiked; + showToast(hasLiked ? '取消点赞' : '点赞成功'); + if (mounted) setState(() {}); + return !hasLiked; + }, + ); + } + + //收藏 + Widget _collectBtn() { + return LikeButton( + size: 20, + isLiked: model?.vidStatus?.hasCollected ?? false, + circleColor: + CircleColor(start: AppColors.actionRed, end: AppColors.actionRed), + bubblesColor: BubblesColor( + dotPrimaryColor: AppColors.actionRed, + dotSecondaryColor: AppColors.actionRed, + ), + likeBuilder: (isLiked) => Image.asset( + isLiked + ? 'collect_red.png'.commonImgPath + : 'collect_empty.png'.commonImgPath, + width: 20, + color: isLiked ? AppColors.actionRed : const Color(0xff989898), + ), + likeCount: model?.collectCount ?? 0, + countBuilder: (count, isLiked, text) => Text( + count.countOr('收藏'), + style: TextStyle( + color: isLiked ? AppColors.actionRed : const Color(0xff989898), + fontSize: 12), + ), + onTap: (isLiked) async { + final hasCollected = model?.vidStatus?.hasCollected ?? false; + final count = model?.collectCount ?? 0; + final isOk = + await MineService.postCollect(model?.id, 'PIC', !hasCollected); + //失败不动本地状态,返回原值让按钮回弹 + if (!isOk) return hasCollected; + model?.collectCount = max(0, count + (hasCollected ? -1 : 1)); + model?.vidStatus?.hasCollected = !hasCollected; + showToast(hasCollected ? '已取消收藏' : '收藏成功'); + if (mounted) setState(() {}); + return !hasCollected; + }, + ); + } + + //图标 + 文字按钮(评论、分享共用) + Widget _actionItem(String imagePath, String title, + {GestureTapCallback? onTap}) { + return InkWell( + enableFeedback: false, + onTap: onTap, + //Container 撑满 70 高,是点击热区,别删 + child: Container( + alignment: Alignment.center, + child: Row( + children: [ + Image.asset(imagePath, width: 20, color: const Color(0xff989898)), + 3.sizeBoxW, + Text(title, + style: const TextStyle(color: Color(0xff989898), fontSize: 12)), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/find/pic_collect/pics_detail_logic.dart b/lib/hj_page/find/pic_collect/pics_detail_logic.dart new file mode 100644 index 0000000..d9e1a4f --- /dev/null +++ b/lib/hj_page/find/pic_collect/pics_detail_logic.dart @@ -0,0 +1,70 @@ +import 'dart:math'; + +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/history_util.dart'; +import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/item_positions_listener.dart'; +import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.dart'; + +import '../../../alert/video/video_buy_bottom_sheet.dart'; +import '../../../alert/video/vip_acg_dialog.dart'; + +class PicsDetailLogic extends GetxController { + // ===== 外部传入 ===== + final String? id; + + PicsDetailLogic(this.id); + + // ===== 数据 ===== + VideoModel? videoModel; + + // ===== Controller ===== + // 两者都没有可调用的释放入口:ItemScrollController 只做 attach/detach; + // ItemPositionsListener 的实现 ItemPositionsNotifier 内部虽持有 ValueNotifier,但包没暴露 dispose。所以本类无 onClose + final itemScrollCtr = ItemScrollController(); + final itemPositionsListener = ItemPositionsListener.create(); + + // ===== 派生 getter ===== + List get covers => videoModel?.seriesCover ?? []; + + //lockType 0-已解锁 1-金币解锁 2-会员解锁 + bool get isUnlocked => videoModel?.lockType == 0; + bool get isCoinLock => videoModel?.lockType == 1; + + //未解锁时放出几张:freeTime 为 0(后端没配)兜底 8 张,否则按 freeTime,都不超过总张数 + //freeTime 为 null 走后半段算 0 张,与原逻辑一致 + int get lockCount => videoModel?.freeTime == 0 + ? min(8, covers.length) + : min(videoModel?.freeTime ?? 0, covers.length); + + @override + void onReady() { + super.onReady(); + loadData(); + } + + Future loadData() async { + videoModel = await VidService.getDetail(id ?? ''); + if (videoModel != null) { + HistoryUtil.insert(videoModel!, MediaStyle.Pic); + } + videoModel ??= VideoModel(); //拉失败也给个空壳,让页面落到错误态而不是一直转圈 + update(); + } + + //解锁:金币走购买弹窗,会员走开通弹窗 + void showBuy() async { + if (isCoinLock) { + final res = + await Get.bottomSheet(VideoBuyBottomSheet(video: videoModel!)); + if (res == true) { + videoModel?.vidStatus?.hasPaid = true; + update(); + } + } else if (videoModel?.lockType == 2) { + Get.dialog(VipACGDialog()); + } + } +} diff --git a/lib/hj_page/find/pic_collect/pics_detail_page.dart b/lib/hj_page/find/pic_collect/pics_detail_page.dart new file mode 100644 index 0000000..a7a5dbb --- /dev/null +++ b/lib/hj_page/find/pic_collect/pics_detail_page.dart @@ -0,0 +1,200 @@ +import 'dart:ui'; + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/hj_utils/widget_util.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/image_browser_page.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/item_positions_listener.dart'; +import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scrollable_positioned_list.dart'; + +import '../../mine/mine_share/mine_share_page.dart'; +import 'album_action_widget.dart'; +import 'pics_detail_logic.dart'; + +//图集详情页 +class PicsDetailPage extends StatefulWidget { + final String? id; + + const PicsDetailPage({super.key, this.id}); + + @override + State createState() => _PicsDetailPageState(); +} + +// 三个入口都是 Get.to(..., preventDuplicates: false),允许叠栈,用 uniqueTag 隔离各实例的 logic +class _PicsDetailPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: GetBuilder( + init: PicsDetailLogic(widget.id), + tag: uniqueTag, + builder: (logic) { + if (logic.videoModel == null) return LoadingCenterWidget(); + //拉失败后 videoModel 是空壳,靠 id 判断 + if (logic.videoModel?.id == null) + return CErrorWidget(retryOnTap: () => logic.loadData()); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + //标题栏 + Row(children: [ + InkWell( + enableFeedback: false, + onTap: () => Get.back(), + child: Icon(Icons.arrow_back_ios_new, + color: Colors.white, size: 24), + ), + 4.sizeBoxW, + Expanded( + child: Text( + logic.videoModel?.title ?? '', + style: textStyle(16, Colors.white.withValues(alpha: .9), + FontWeight.w800), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ) + ]).paddingSymmetric(horizontal: 16, vertical: 12), + Expanded(child: _picList(logic)), + //底部操作条(页码/评论/点赞/分享),未解锁传 -1 让它藏起页码 + ValueListenableBuilder>( + valueListenable: logic.itemPositionsListener.itemPositions, + builder: (_, positions, __) => AlbumActionWidget( + videoModel: logic.videoModel, + currentIndex: logic.isUnlocked + ? (positions.isEmpty ? 1 : positions.first.index) + : -1, + ), + ), + ], + ); + }, + ), + ), + ); + } + + //图片列表:已解锁全量、点开看大图;未解锁只放 lockCount 张,最后一张压解锁蒙层 + Widget _picList(PicsDetailLogic logic) { + final covers = logic.covers; + final isUnlocked = logic.isUnlocked; + final count = isUnlocked ? covers.length : logic.lockCount; + return ScrollablePositionedList.builder( + itemPositionsListener: logic.itemPositionsListener, + itemScrollController: logic.itemScrollCtr, + itemCount: count, + itemBuilder: (_, index) { + final image = + NetworkImageLoader(imageUrl: covers[index], borderRadius: 0); + if (isUnlocked) { + return GestureDetector( + onTap: () => ImageBrowserPage.open(covers, index: index), + child: image); + } + return Stack( + children: [ + InkWell( + enableFeedback: false, + onTap: () => showToast('解锁后可查看图片详情'), + child: image), + if (index == count - 1) Positioned.fill(child: _unlockMask(logic)), + ], + ); + }, + ); + } + + //解锁蒙层:高度由那张图决定,图太矮时整体等比缩小,别撑破 + Widget _unlockMask(PicsDetailLogic logic) { + final coins = logic.videoModel?.originCoins ?? 0; + final List content = logic.isCoinLock + ? [ + //金币解锁 + EasyRichText( + '你只需花费 $coins 金币', + defaultStyle: textStyle(16, Colors.white, FontWeight.w400), + patternList: [ + EasyRichTextPattern( + targetString: '$coins', + style: textStyle(16, AppColors.actionRed, FontWeight.w400)), + ], + ), + 16.sizeBoxH, + Text('即可解锁继续查看', + style: textStyle(12, Colors.white, FontWeight.w400)), + 16.sizeBoxH, + _maskBtn('立即解锁', 84, [Color(0xffFF6E6E), Color(0xffFF4D4D)], + Colors.white, logic.showBuy), + ] + : [ + //会员解锁 + Text('试看结束', style: textStyle(16, Colors.white, FontWeight.w400)), + 16.sizeBoxH, + Text('本内容需开通VIP免费观看', + style: textStyle(16, Colors.white, FontWeight.w400)), + 16.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _maskBtn( + '邀请得3日VIP', + 110, + [Color(0xffFFE8BE), Color(0xffE6B764)], + Color(0xff694923), + () => Get.to(() => MineSharePage())), + 18.sizeBoxW, + _maskBtn( + '开通VIP免费看', + 115, + [Color(0xffFF6E6E), Color(0xffFF4D4D)], + Colors.white, + logic.showBuy), + ], + ), + ]; + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4), + child: Container( + color: Colors.black.withValues(alpha: .6), + child: FittedBox( + fit: BoxFit.scaleDown, + child: SizedBox( + width: screen.screenWidth, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: content), + ), + ), + ), + ), + ); + } + + //蒙层里的胶囊按钮,三处只差宽度/渐变/文字色/回调 + Widget _maskBtn(String text, double width, List colors, + Color textColor, VoidCallback onTap) { + return InkWell( + enableFeedback: false, + onTap: onTap, + child: Container( + width: width, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: LinearGradient(colors: colors), + borderRadius: BorderRadius.circular(15)), + child: Text(text, style: textStyle(12, textColor, FontWeight.w400)), + ), + ); + } +} diff --git a/lib/hj_page/find/rank_module/acg_rank_item.dart b/lib/hj_page/find/rank_module/acg_rank_item.dart new file mode 100644 index 0000000..522a759 --- /dev/null +++ b/lib/hj_page/find/rank_module/acg_rank_item.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/cartoon_media_info.dart'; +import 'package:hgdj/hj_page/find/rank_module/rank_badge.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +//动漫/漫画排行榜item +class ACGRankItem extends StatelessWidget { + final int index; + final CartoonMediaInfo? movie; + + const ACGRankItem(this.index, this.movie, {super.key}); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: () => pushToCartoonPage(movie), + child: SizedBox( + height: 108, + child: Row( + children: [ + //竖版封面 + SizedBox( + width: 80, + height: 108, + child: NetworkImageLoader( + imageUrl: movie?.coverV ?? '', + borderRadius: 8, + ), + ), + 10.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + rankBadge(index), + //标题 + Text( + movie?.title ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 12, + height: 1.5, + ), + ), + //集数 · 更新状态 + Text( + '${movie?.episodeNumberStatus ?? ''} · ${movie?.updateDesc ?? ''}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: _statusColor, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + //连载中金色,已完结半透明白 + Color get _statusColor => movie?.updateStatus == 1 + ? const Color(0xffEEC76B) + : Colors.white.withValues(alpha: .45); +} diff --git a/lib/hj_page/find/rank_module/image_rank_item.dart b/lib/hj_page/find/rank_module/image_rank_item.dart new file mode 100644 index 0000000..dab217a --- /dev/null +++ b/lib/hj_page/find/rank_module/image_rank_item.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/find/pic_collect/pics_detail_page.dart'; +import 'package:hgdj/hj_page/find/rank_module/rank_badge.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +//图集排行榜item +class ImageRankItem extends StatelessWidget { + final int index; + final VideoModel? movie; + + const ImageRankItem(this.index, this.movie, {super.key}); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: () => + Get.to(PicsDetailPage(id: movie?.id), preventDuplicates: false), + child: SizedBox( + height: 108, + child: Row( + children: [ + //封面 + SizedBox( + width: 80, + height: 108, + child: NetworkImageLoader( + imageUrl: movie?.cover ?? '', + borderRadius: 8, + ), + ), + 10.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + rankBadge(index), + //标题 + Text( + movie?.title ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 12, + height: 1.5, + ), + ), + //浏览量 + 收藏量 + Row( + children: [ + Image.asset("eye_grey.webp".commonImgPath, + width: 14, height: 14), + 5.sizeBoxW, + Text( + movie?.playCount?.countStr ?? "0", + style: const TextStyle( + color: Color(0xff989898), fontSize: 12), + ), + 8.sizeBoxW, + Image.asset("collect_start.webp".homePath, width: 14), + 5.sizeBoxW, + Text( + movie?.collectCount?.countStr ?? "0", + style: const TextStyle( + color: Color(0xff989898), fontSize: 12), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/find/rank_module/rank_badge.dart b/lib/hj_page/find/rank_module/rank_badge.dart new file mode 100644 index 0000000..3d54de8 --- /dev/null +++ b/lib/hj_page/find/rank_module/rank_badge.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +/// 榜单名次徽标「皇冠 + NO.1」:影视 / 图集 / 动漫 / 帖子四种 item 共用,改样式只改这一处 +Widget rankBadge(int index) { + final color = _rankColor(index); + return Row( + children: [ + //4 个名次共用同一张皇冠形状图,只换颜色 + Image.asset("rank_crown.webp".homePath, width: 24, color: color), + 4.sizeBoxW, + Text("NO.${index + 1}", style: TextStyle(color: color, fontSize: 14)), + ], + ); +} + +/// 名次配色:金 / 粉 / 蓝,第 4 名往后统一灰 +Color _rankColor(int index) => switch (index) { + 0 => const Color(0xffFFD059), + 1 => const Color(0xffF24A71), + 2 => const Color(0xff5DCAE7), + _ => const Color(0xffbdbdbd), + }; diff --git a/lib/hj_page/find/rank_module/rank_main_logic.dart b/lib/hj_page/find/rank_module/rank_main_logic.dart new file mode 100644 index 0000000..29c5165 --- /dev/null +++ b/lib/hj_page/find/rank_module/rank_main_logic.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/const.dart'; + +class RankMainLogic extends GetxController with GetTickerProviderStateMixin { + RankMainLogic({int initIndex = 0}) : _initIndex = initIndex; + + final int _initIndex; + + //榜单类型,顺序即 tab 顺序 + static const tabs = [MediaStyle.Video, MediaStyle.Community, MediaStyle.Pic]; + + late final TabController tabCtr = + TabController(initialIndex: _initIndex, length: tabs.length, vsync: this); + + @override + void onClose() { + tabCtr.dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/find/rank_module/rank_main_page.dart b/lib/hj_page/find/rank_module/rank_main_page.dart new file mode 100644 index 0000000..bd33a9c --- /dev/null +++ b/lib/hj_page/find/rank_module/rank_main_page.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/find/rank_module/rank_type_tab_view.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import 'rank_main_logic.dart'; + +/// 排行榜主页:顶部 TabBar 切换榜单类型(影片/帖子/图集),下方 TabBarView 展示各类型榜单列表。 +class RankMainPage extends StatefulWidget { + final int index; // 初始选中的 Tab 下标 + + const RankMainPage({super.key, this.index = 0}); + + @override + State createState() => _RankMainPageState(); +} + +class _RankMainPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: RankMainLogic(initIndex: widget.index), + tag: uniqueTag, + builder: (logic) => Scaffold( + appBar: AppBar(title: const Text('热门排行')), + body: Column( + children: [ + //顶部:榜单类型 Tab + Container( + height: 44, + alignment: Alignment.center, + child: TabBar( + controller: logic.tabCtr, + padding: const EdgeInsets.symmetric(horizontal: 30), + tabs: RankMainLogic.tabs + .map((e) => Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Text(e.rankTitle))) + .toList(), + labelPadding: const EdgeInsets.symmetric(horizontal: 3), + unselectedLabelColor: const Color(0x8CFFFFFF), + unselectedLabelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + labelColor: const Color(0xE5FFFFFF), + labelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + indicator: CustomIndicator( + height: 3, + width: 13, + isGradient: true, + offsetY: 2, + borderRadius: BorderRadius.circular(1.5), + ), + ), + ), + //下方:各榜单类型对应的列表(keepAlive 切换不重建) + Expanded( + child: TabBarView( + controller: logic.tabCtr, + children: RankMainLogic.tabs + .map((e) => RankTypeTabView(e).keepAlive) + .toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/find/rank_module/rank_sort_tab_bar.dart b/lib/hj_page/find/rank_module/rank_sort_tab_bar.dart new file mode 100644 index 0000000..c72d029 --- /dev/null +++ b/lib/hj_page/find/rank_module/rank_sort_tab_bar.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +/// 榜单周期 Tab:周榜 / 月榜 / 年榜 / 总榜,项间用一条竖线分隔、无下划线指示器。 +/// 不用自己监听 controller 重绘——TabBar 内部就挂着 animation 监听 +class RankSortTabBar extends StatelessWidget { + final List titles; + final TabController? tabCtr; + + const RankSortTabBar({super.key, required this.titles, this.tabCtr}); + + @override + Widget build(BuildContext context) { + return TabBar( + controller: tabCtr, + tabs: titles + .asMap() + .entries + .map((e) => Row( + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 18, vertical: 3), + child: Text(e.value), + ), + if (e.key != titles.length - 1) 12.lineV, + ], + )) + .toList(), + isScrollable: true, + labelPadding: EdgeInsets.zero, + labelStyle: const TextStyle( + fontSize: 14, + height: 1.5, + color: Color(0xE5FFFFFF), + fontWeight: FontWeight.w500, + ), + unselectedLabelStyle: const TextStyle( + fontSize: 14, + height: 1.5, + fontWeight: FontWeight.w400, + color: Color(0x8CFFFFFF), + ), + indicator: const BoxDecoration(), + ); + } +} diff --git a/lib/hj_page/find/rank_module/rank_sub_logic.dart b/lib/hj_page/find/rank_module/rank_sub_logic.dart new file mode 100644 index 0000000..f1ec752 --- /dev/null +++ b/lib/hj_page/find/rank_module/rank_sub_logic.dart @@ -0,0 +1,43 @@ +import 'package:hgdj/hj_utils/api_service/acg_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +class RankSubPageLogic extends ListBaseLogic { + final MediaStyle pageType; // 榜单媒体类型:影片/帖子/图集/漫画等 + final int sortType; // 榜单周期:2周 3月 5年 4总 + + RankSubPageLogic({required this.pageType, this.sortType = 2}); + + bool get isACG => + pageType == MediaStyle.Comics || pageType == MediaStyle.Cartoon; + + // onInit(挂载即同步触发,比 onReady 的帧后回调在嵌套 keepAlive 下可靠) + @override + void onInit() { + super.onInit(); + loadData(); + } + + // 下拉刷新 / 上拉加载;showLoading 用于重试时先清空显示 loading。 + // 必须把 Future 交出去:CErrorWidget 靠它做重试 loading + 防连点 + Future loadData({bool isRefresh = true, bool showLoading = false}) { + if (showLoading) { + dataList = null; + update(); + } + return fetchData(isRefresh: isRefresh, fetch: _fetch); + } + + // 拉取榜单:ACG 走 getRanking,其余走 vidRanking;返回 (列表, 是否还有更多) + Future<(List?, bool)> _fetch(int page) async { + if (isACG) { + final resp = await ACGService.getRanking(page, 10, + mediaType: pageType.rankParam, type: sortType); + return (resp?.list, resp?.hasNext == true); + } + final res = await VidService.vidRanking(page, 10, + newsType: pageType.rankParam, type: sortType); + return (res?.videos, res?.hasNext == true); + } +} diff --git a/lib/hj_page/find/rank_module/rank_sub_page.dart b/lib/hj_page/find/rank_module/rank_sub_page.dart new file mode 100644 index 0000000..93ced5f --- /dev/null +++ b/lib/hj_page/find/rank_module/rank_sub_page.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/find/rank_module/video_rank_item.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +import '../../community/widget/community_post_widget.dart'; +import 'acg_rank_item.dart'; +import 'image_rank_item.dart'; +import 'rank_sub_logic.dart'; + +class RankSubPage extends StatefulWidget { + final MediaStyle pageType; + final int sortType; //榜单周期:2周 3月 5年 4总 + + const RankSubPage({super.key, required this.pageType, this.sortType = 2}); + + @override + State createState() => _RankSubPageState(); +} + +// UniqueTagMixin:每次挂载都是全新 tag/controller,保证 onInit 触发、避免复用旧 logic +class _RankSubPageState extends State with UniqueTagMixin { + late final RankSubPageLogic logic = RankSubPageLogic( + pageType: widget.pageType, + sortType: widget.sortType, + ); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: logic, + tag: uniqueTag, + builder: (_) => pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onRefresh: (ctr) => logic.loadData(), + onLoading: (ctr) => logic.loadData(isRefresh: false), + child: _buildContent(), + ), + ); + } + + Widget _buildContent() { + if (logic.isLoading) return LoadingCenterWidget(); + //retryOnTap 要拿到 Future 才有 loading + 防连点 + if (logic.isEmptyData) + return CErrorWidget(retryOnTap: () => logic.loadData(showLoading: true)); + final list = logic.dataList ?? const []; + return ListView.separated( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: list.length, + separatorBuilder: (_, __) => 10.sizeBoxH, + itemBuilder: (_, index) => switch (logic.pageType) { + MediaStyle.Video => VideoRankItem(index, list[index]), + MediaStyle.Pic => ImageRankItem(index, list[index]), + //帖子要能左右滑浏览同榜其他帖,整表一起递进去(cast 是惰性视图,不拷贝) + MediaStyle.Community => CommunityPostWidget( + padding: EdgeInsets.zero, + videoModel: list[index], + videoModels: list.cast(), + rankIndex: index, + ), + MediaStyle.Cartoon || + MediaStyle.Comics => + ACGRankItem(index, list[index]), + _ => const SizedBox.shrink(), + }, + ); + } +} diff --git a/lib/hj_page/find/rank_module/rank_type_tab_view.dart b/lib/hj_page/find/rank_module/rank_type_tab_view.dart new file mode 100644 index 0000000..87cddcc --- /dev/null +++ b/lib/hj_page/find/rank_module/rank_type_tab_view.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_page/find/rank_module/rank_sort_tab_bar.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import 'rank_sub_page.dart'; + +/// 某一类榜单(影片/帖子/图集/动漫…)下的周期分页:周榜 / 月榜 / 年榜 / 总榜 +class RankTypeTabView extends StatefulWidget { + final MediaStyle pageType; + + const RankTypeTabView(this.pageType, {super.key}); + + @override + State createState() => _RankTypeTabViewState(); +} + +class _RankTypeTabViewState extends State + with SingleTickerProviderStateMixin { + //标题与后端 sortType 绑在一起,避免两个数组分家后漏改一处就错位 + static const _tabs = [ + SortTab('周榜', 2), + SortTab('月榜', 3), + SortTab('年榜', 5), + SortTab('总榜', 4), + ]; + + late final TabController tabCtr = + TabController(length: _tabs.length, vsync: this); + + @override + void dispose() { + tabCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + RankSortTabBar( + titles: _tabs.map((e) => e.name).toList(), tabCtr: tabCtr), + 8.sizeBoxH, + Expanded( + child: TabBarView( + controller: tabCtr, + children: _tabs + .map((e) => + RankSubPage(pageType: widget.pageType, sortType: e.sort) + .keepAlive) + .toList(), + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/find/rank_module/video_rank_item.dart b/lib/hj_page/find/rank_module/video_rank_item.dart new file mode 100644 index 0000000..089119b --- /dev/null +++ b/lib/hj_page/find/rank_module/video_rank_item.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/find/rank_module/rank_badge.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +//影视排行榜item +class VideoRankItem extends StatelessWidget { + final int index; + final VideoModel? movie; + + const VideoRankItem(this.index, this.movie, {super.key}); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: () => pushToVideoPage(videoModel: movie), + child: SizedBox( + height: 95, + child: Row( + children: [ + //横版封面,左下播放量、右下时长 + SizedBox( + width: 168, + height: 95, + child: Stack( + fit: StackFit.expand, + children: [ + NetworkImageLoader( + imageUrl: movie?.cover ?? '', borderRadius: 8), + Container( + alignment: Alignment.bottomCenter, + padding: const EdgeInsets.fromLTRB(5, 0, 5, 5), + child: Row( + children: [ + Image.asset('circle_play.webp'.videoPath, width: 16), + Text( + movie?.playCount?.countStr ?? '0', + style: const TextStyle( + color: Colors.white, fontSize: 10, height: 1.6), + ), + const Spacer(), + Text( + movie?.playTime?.hmsStr ?? "", + style: const TextStyle( + color: Colors.white, fontSize: 10, height: 1.6), + ), + ], + ), + ), + ], + ), + ), + 10.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + rankBadge(index), + //末尾补个换行:单行标题也占满两行高,三行内容的间距才不会随标题长短跳 + Text( + "${movie?.title ?? ''}\n", + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xE5FFFFFF), fontSize: 12, height: 1.5), + ), + //收藏量 + Row( + children: [ + Image.asset("collect_start.webp".homePath, width: 14), + 5.sizeBoxW, + Text( + movie?.collectCount?.countStr ?? "0", + style: const TextStyle( + color: Color(0xff989898), fontSize: 12), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/home/activity_float/home_activity_float.dart b/lib/hj_page/home/activity_float/home_activity_float.dart new file mode 100644 index 0000000..3efd19e --- /dev/null +++ b/lib/hj_page/home/activity_float/home_activity_float.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/splash/domain_source_model.dart'; +import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart'; +import 'package:hgdj/hj_page/mine/mine_vip/mine_charge_vip_page.dart'; +import 'package:hgdj/hj_page/mine/mine_vip/pay_order_source.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../tools_base/widget/card_swiper/src/swiper.dart'; +import '../../pre_sale/pre_sale_entry.dart'; +import '../../pre_sale/pre_sale_provider.dart'; + +/// 浮窗容器的位置锚点:支付分层弹窗关闭时缩小飞向它(见 GuideHomeDialog)。 +/// 挂在容器而不是分层浮窗本身——轮播时子项会轮到屏幕外,落点得是稳定的右下角 +final homeFloatKey = GlobalKey(); + +/// 首页右下角浮窗入口 +/// 展示优先级:预售浮窗 + 分层浮窗,两者都在则轮播(预售在前);都没有则不显示 +class HomeActivityFloat extends StatelessWidget { + const HomeActivityFloat({super.key}); + + @override + Widget build(BuildContext context) { + // 预售(PreSaleProvider)、分层(MineMsgProvider)配置变化时重建 + return Consumer2( + builder: (_, presale, msg, __) { + final tier = msg.payTier?.config; + // 按优先级收集可展示的浮窗:预售在前、分层在后 + final floats = [ + //预售浮窗:展示判断走项目统一的 canShowEnter,点击进预售页(入口图/动画由 PreSaleEntry 内部处理) + if (presale.canShowEnter) const PreSaleEntry(), + //支付分层浮窗:有 homePageFlot 图就展示 + if ((tier?.homePageFlot ?? '').isNotEmpty) + _LayeredFloatView(config: tier!), + ]; + if (floats.isEmpty) return const SizedBox(); + + // 单个直接贴右下角;预售 + 分层并存则轮播 + return KeyedSubtree( + key: homeFloatKey, + child: floats.length == 1 + ? Align(alignment: Alignment.bottomRight, child: floats.first) + : _FloatCarousel(items: floats), + ); + }, + ); + } +} + +/// 支付分层浮窗:homePageFlot 海报图(自带倒计时框)+ 底部叠加 lastDiscountTime 倒计时文字 +/// 点击跳会员中心并自动选中 config.vipCard 对应会员卡 +class _LayeredFloatView extends StatelessWidget { + final PayTierConfig config; + + const _LayeredFloatView({required this.config}); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + //点击跳会员中心,vipID 让其自动选中对应卡 + onTap: () => Get.to(() => MineChargeVipPage( + vipID: config.vipCard, sourcePage: PaySourcePage.homeFloatWindow)), + child: SizedBox( + width: 60, + height: 66, + child: Stack( + fit: StackFit.expand, + children: [ + //分层海报图(后端加密图) + NetworkImageLoader( + imageUrl: config.homePageFlot ?? '', + fit: BoxFit.fill, + borderRadius: 0), + //倒计时文字:监听 tick 每秒刷新(过期自动收起),框由后端图自带,位置不对就调 bottom + Positioned( + left: 0, + right: 0, + bottom: 2, + child: ValueListenableBuilder( + valueListenable: MineMsgProvider().tick, + builder: (_, __, ___) => config.hasDiscountCountdown + ? Center( + child: Text( + '${config.discountHour}:${config.discountMin}:${config.discountSec}', + style: const TextStyle( + color: Colors.white, + fontSize: 8.5, + fontWeight: FontWeight.w600), + ), + ) + : const SizedBox.shrink(), + ), + ), + ], + ), + ), + ); + } +} + +/// 预售 + 分层浮窗轮播(复用项目 Swiper,4s 自动切换;预售在前、分层在后) +class _FloatCarousel extends StatelessWidget { + final List items; + + const _FloatCarousel({required this.items}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 70, + height: 124, + child: Swiper( + //内容数量变化时换 key 强制重建,避免 index 越界 + key: ValueKey('home_float_${items.length}'), + autoplay: true, + autoplayDelay: 4000, + loop: true, + itemCount: items.length, + itemBuilder: (_, index) => + Align(alignment: Alignment.bottomRight, child: items[index]), + ), + ); + } +} diff --git a/lib/hj_page/home/activity_float/home_float_banner.dart b/lib/hj_page/home/activity_float/home_float_banner.dart new file mode 100644 index 0000000..04fe151 --- /dev/null +++ b/lib/hj_page/home/activity_float/home_float_banner.dart @@ -0,0 +1,242 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; + +import '../../../config/config.dart'; +import '../../../hj_model/splash/domain_source_model.dart'; +import '../../../hj_utils/api_service/common_service.dart'; +import '../../../routers/jump_router.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import '../../mine/mine_vip/pay_order_source.dart'; + +/// 首页红包雨/限时活动浮窗:展示倒计时,活动结束后 ping 刷新下一场 +class HomeFloatBanner extends StatefulWidget { + final BannerJumpEntity banner; + final double width; + final double height; + final double radius; + + /// banner 被 ping 刷新/移除后回调,通知父级(轮播)重建 + final VoidCallback? onBannerUpdated; + + const HomeFloatBanner({ + super.key, + required this.banner, + required this.width, + required this.height, + this.radius = 0, + this.onBannerUpdated, + }); + + @override + State createState() => _HomeFloatBannerState(); +} + +class _HomeFloatBannerState extends State { + // ===== 数据 ===== + late BannerJumpEntity _banner; // 当前展示的 banner(活动结束后可能被 ping 刷新替换) + + // ===== 状态标志 ===== + bool _endedPingRequested = false; // 本场活动结束后是否已请求过刷新(避免重复 ping) + bool _isPinging = false; // 是否正在 ping(防重入) + + // ===== 计时器 & 局部刷新 ===== + Timer? _timer; // 倒计时类活动每秒刷新用 + // 底部文案(倒计时 / "立即参与")。每秒只更新它,交 ValueListenableBuilder 局部刷新文字, + // 图片(NetworkImageLoader)不参与每秒重建(仿 PreSaleProvider.deadline 的局部刷新思路) + final ValueNotifier _bottomText = ValueNotifier(''); + + // 是否倒计时类活动(countdownType==1 才展示倒计时并轮询结束) + bool get _isCountdownActivity => _banner.countdownType == 1; + + @override + void initState() { + super.initState(); + _banner = widget.banner; + _refreshBottomText(); + _restartTimer(); + } + + @override + void didUpdateWidget(covariant HomeFloatBanner oldWidget) { + super.didUpdateWidget(oldWidget); + // banner 换了(父级传入新数据):重置结束标记、刷新文案并按新类型重启计时器 + if (oldWidget.banner != widget.banner) { + _banner = widget.banner; + _endedPingRequested = false; + _refreshBottomText(); + _restartTimer(); + } + } + + @override + void dispose() { + _timer?.cancel(); + _bottomText.dispose(); + super.dispose(); + } + + // 按当前 banner 类型启停计时器:仅倒计时类活动才每秒刷新 + void _restartTimer() { + _timer?.cancel(); + _timer = null; + if (_isCountdownActivity) { + _timer = + Timer.periodic(const Duration(seconds: 1), (_) => _onTimerTick()); + } + } + + // 每秒 tick:活动结束时 ping 一次拿下一场,其余情况只更新底部文案(局部刷新,不重建图片) + void _onTimerTick() { + if (!mounted) return; + if (_activityStatus() == _ActStatus.ended && !_endedPingRequested) { + _endedPingRequested = true; + _fetchLatestBanner(); + } + _refreshBottomText(); + } + + // 重算底部文案:进行中"立即参与"、未开始显示距开始倒计时、已结束为空。 + // 只写 ValueNotifier,触发 ValueListenableBuilder 局部刷新 + void _refreshBottomText() { + if (!_isCountdownActivity) { + _bottomText.value = ''; + return; + } + final start = _parseDate(_banner.startAt); + _bottomText.value = switch (_activityStatus()) { + _ActStatus.inProgress => '立即参与', + _ActStatus.beforeStart => + start == null ? '' : _formatCountdown(start.difference(DateTime.now())), + _ActStatus.ended => '', + }; + } + + // 活动结束后拉最新 banner:有新场次则替换并写回 Config,无则从 Config 移除 + Future _fetchLatestBanner() async { + final id = _banner.id; + if (id == null || id.isEmpty || _isPinging) return; + _isPinging = true; + try { + final latest = await CommonService.pingBanner(id); + if (!mounted) return; + if (latest != null && latest.banner?.isNotEmpty == true) { + _banner = latest; + _updateConfigBanner(latest); + _endedPingRequested = false; + } else { + _removeConfigBanner(); + } + widget.onBannerUpdated?.call(); + } catch (_) { + } finally { + _isPinging = false; + if (mounted) { + _refreshBottomText(); + setState(() {}); + } + } + } + + // 用新 banner 替换 Config.bannerJumps 里同位置同 id 的旧数据 + void _updateConfigBanner(BannerJumpEntity banner) { + final index = Config.bannerJumps.indexWhere( + (e) => e.position == _banner.position && e.id == _banner.id); + if (index >= 0) { + Config.bannerJumps[index] = banner; + } + } + + // 活动结束且无下一场:从 Config.bannerJumps 移除当前 banner + void _removeConfigBanner() { + Config.bannerJumps.removeWhere( + (e) => e.position == _banner.position && e.id == _banner.id); + } + + // 解析后端时间字符串为本地时间;空值/占位(0001-01-01)/非法格式返回 null + DateTime? _parseDate(String? date) { + if (date == null || date.isEmpty || date.contains('0001-01-01')) + return null; + try { + return DateTime.parse(date).toLocal(); + } catch (_) { + return null; + } + } + + // 依据 startAt/endAt 判定活动处于 未开始 / 进行中 / 已结束 + _ActStatus _activityStatus() { + final now = DateTime.now(); + final start = _parseDate(_banner.startAt); + final end = _parseDate(_banner.endAt); + if (start != null && now.isBefore(start)) return _ActStatus.beforeStart; + if (end != null && !now.isBefore(end)) return _ActStatus.ended; + return _ActStatus.inProgress; + } + + // 倒计时格式化:跨天显示"N天HH:MM:SS",否则"HH:MM:SS" + String _formatCountdown(Duration duration) { + final seconds = duration.inSeconds < 0 ? 0 : duration.inSeconds; + final days = seconds ~/ 86400; + final time = DateTimeUtil.formatHMS(seconds % 86400, + alwaysHour: true); // 当天内 HH:MM:SS + return days > 0 ? '$days天$time' : time; + } + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + // 内链可配到会员/金币页,来源统一记首页浮窗 + onTap: () => pushToPageByLink(_banner.url, + sourcePage: PaySourcePage.homeFloatWindow), + child: SizedBox( + width: widget.width, + height: widget.height, + child: Stack( + children: [ + NetworkImageLoader( + imageUrl: _banner.banner ?? '', + width: widget.width, + height: widget.height, + borderRadius: widget.radius, + fit: BoxFit.cover, + ), + Positioned( + left: 0, + right: 0, + bottom: 2, + child: ValueListenableBuilder( + valueListenable: _bottomText, + builder: (_, text, __) => text.isEmpty + ? const SizedBox.shrink() + : Text( + text, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +// 浮窗活动状态:未开始 / 进行中 / 已结束 +enum _ActStatus { beforeStart, inProgress, ended } + +/// 是否用 [HomeFloatBanner] 渲染该 banner:仅 position==3 的浮窗位、有图、且非「抽奖」项 +bool isHomeFloatBanner(BannerJumpEntity model, int position) { + if (position != 3) return false; + if (model.banner?.isNotEmpty != true) return false; + return !(model.title == '抽奖' && model.position == null); +} diff --git a/lib/hj_page/home/activity_float/home_float_widget.dart b/lib/hj_page/home/activity_float/home_float_widget.dart new file mode 100644 index 0000000..3e30975 --- /dev/null +++ b/lib/hj_page/home/activity_float/home_float_widget.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'home_activity_float.dart'; +import 'swiper_floating_widget.dart'; + +class HomeFloatWidget extends StatelessWidget { + const HomeFloatWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // 预售 + 支付分层浮窗(编排 + 轮播统一在 HomeActivityFloat) + const HomeActivityFloat(), + SwiperFloatingWidget( + position: 3, + width: 64, + height: 64, + margin: EdgeInsets.only(top: 16), + ), + ], + ); + } +} diff --git a/lib/hj_page/home/activity_float/swiper_floating_widget.dart b/lib/hj_page/home/activity_float/swiper_floating_widget.dart new file mode 100644 index 0000000..9a515a1 --- /dev/null +++ b/lib/hj_page/home/activity_float/swiper_floating_widget.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; + +import '../../../config/config.dart'; +import '../../../hj_model/splash/domain_source_model.dart'; +import '../../../routers/jump_router.dart'; +import '../../../tools_base/widget/card_swiper/src/swiper.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import '../../mine/mine_vip/pay_order_source.dart'; +import '../../pre_sale/pre_sale_entry.dart'; +import '../../pre_sale/pre_sale_provider.dart'; +import 'home_float_banner.dart'; + +/// 首页浮窗入口:聚合 Config.bannerJumps 中指定 position 的 banner + 抽奖入口,轮播展示。 +/// banner 命中 [isHomeFloatBanner] 用带倒计时的 [HomeFloatBanner],抽奖用 [LuckyDrawButton],其余普通图。 +class SwiperFloatingWidget extends StatefulWidget { + // 必填:浮窗位置标识(对应 bannerJumps 的 position) + final int position; + + // ===== 尺寸 / 外观 ===== + final double? width; + final double? height; + final double? radius; + final EdgeInsetsGeometry? margin; + + // ===== 行为 ===== + final int? autoPlayDuration; // 轮播间隔(毫秒) + final ValueChanged? onItemClick; // 自定义点击;不传则走默认 pushToPageByLink + + const SwiperFloatingWidget({ + required this.position, + super.key, + this.width, + this.height, + this.radius, + this.margin, + this.autoPlayDuration = 5000, + this.onItemClick, + }); + + @override + State createState() => _SwiperFloatingWidgetState(); +} + +class _SwiperFloatingWidgetState extends State { + int _currentIndex = 0; // 当前轮播页下标 + + // 当前可见的浮窗项:本 position 下有图的 banner +(开启时)抽奖入口 + List get _visibleBanners { + final banners = Config.bannerJumps + .where((e) => e.position == widget.position && e.banner?.isNotEmpty == true) + .toList(); + if (presaleProvider.luckyDrawH5?.isNotEmpty == true) { + banners.add(BannerJumpEntity(title: '抽奖')); + } + return banners; + } + + // 是否抽奖入口项(title=抽奖 且无 position,借此与真实 banner 区分) + bool _isLuckyDraw(BannerJumpEntity model) => model.title == '抽奖' && model.position == null; + + // Swiper 的 ValueKey:position + 抽奖态 + 各 banner id,内容变化时换 key 强制重建 + String _buildSwiperKey(List banners) { + var key = 'home_float_${widget.position}'; + if (presaleProvider.luckyDrawH5?.isNotEmpty == true) key += '_lucky'; + for (final banner in banners) { + if (_isLuckyDraw(banner)) continue; + key += '_banner${banner.id ?? ''}'; + } + return key; + } + + @override + Widget build(BuildContext context) { + final banners = _visibleBanners; // 每帧只构建一次,key/itemBuilder/onTap 共用同一快照 + if (banners.isEmpty) return const SizedBox(); + + final itemW = widget.width ?? 64.0; + final itemH = widget.height ?? 64.0; + final radius = widget.radius ?? 0.0; + return Container( + width: widget.width, + height: widget.height, + margin: widget.margin, + child: Swiper( + key: ValueKey(_buildSwiperKey(banners)), + index: _currentIndex, + autoplay: banners.length > 1, + autoplayDelay: widget.autoPlayDuration ?? 5000, + loop: banners.length != 1, + itemCount: banners.length, + itemBuilder: (_, index) => _buildItem(banners[index], itemW, itemH, radius), + onTap: (index) => _onItemTap(banners[index]), + onIndexChanged: (index) { + if (mounted) setState(() => _currentIndex = index); + }, + ), + ); + } + + // 单项:抽奖入口 / 带倒计时的活动浮窗 / 普通图,统一右对齐 + Widget _buildItem(BannerJumpEntity model, double width, double height, double radius) { + Widget child; + if (_isLuckyDraw(model)) { + child = const LuckyDrawButton(); + } else if (isHomeFloatBanner(model, widget.position)) { + child = HomeFloatBanner( + banner: model, + width: width, + height: height, + radius: radius, + onBannerUpdated: () { + if (mounted) setState(() {}); + }, + ); + } else { + child = NetworkImageLoader( + imageUrl: model.banner ?? '', + width: width, + height: height, + borderRadius: radius, + fit: BoxFit.cover, + ); + } + return Align(alignment: Alignment.centerRight, child: child); + } + + // 点击:抽奖/活动浮窗自行处理点击(此处跳过),其余走自定义回调或默认跳转 + void _onItemTap(BannerJumpEntity ad) { + if (_isLuckyDraw(ad)) return; + if (isHomeFloatBanner(ad, widget.position)) return; + if (widget.onItemClick != null) { + widget.onItemClick!.call(ad); + } else { + // 内链可配到会员/金币页,来源统一记首页浮窗 + pushToPageByLink( + ad.url, + sourcePage: PaySourcePage.homeFloatWindow, + ); + } + } +} diff --git a/lib/hj_page/home/dark_home_page.dart b/lib/hj_page/home/dark_home_page.dart new file mode 100644 index 0000000..769455f --- /dev/null +++ b/lib/hj_page/home/dark_home_page.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../alert/aw_permission_alert.dart'; +import '../../hj_utils/screen.dart'; +import '../../tools_base/global_store/store.dart'; +import 'home_drawer.dart'; +import 'home_main_logic.dart'; +import 'home_main_page.dart'; +import 'search_page/widget/common_search_widget.dart'; + +class DarkHomePage extends StatefulWidget { + final String? defaultId; + const DarkHomePage({super.key, this.defaultId}); + + @override + State createState() => _DarkHomePageState(); +} + +class _DarkHomePageState extends State { + late final logic = + HomeMainLogic(isDarkStyle: true, darkDefaultId: widget.defaultId); + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xff050f17), + child: GetBuilder( + init: logic, + tag: "HomeMainLogic_dark", + builder: (_) { + return Stack( + fit: StackFit.expand, + children: [ + Scaffold( + //openEndDrawer 靠这个 key 拿 ScaffoldState,不挂 key 点右上角菜单没反应 + key: logic.scaffoldKey, + backgroundColor: Colors.black, + endDrawer: HSHomeDrawer(homeLogic: logic), + body: logic.isLoading + ? const LoadingCenterWidget() + : Column( + children: [ + Container( + margin: EdgeInsets.only(top: screen.paddingTop), + height: kToolbarHeight, + //必须传 logic,右上角菜单的 onTap 是 logic?.openEndDrawer() + child: CommonSearchBarView(logic: logic), + ), + Expanded( + child: HomeTabView( + key: ValueKey(logic.tabViewKey), + logic: logic, + isDarkStyle: true, + ), + ), + ], + ), + ), + Consumer(builder: (_, store, __) { + if (store.isAWVIP) return const SizedBox.shrink(); + return const Positioned.fill( + child: AwPermissionAlert(), + ); + }), + ], + ); + }, + ), + ); + } +} diff --git a/lib/hj_page/home/home_cell_style/divider_tab_bar.dart b/lib/hj_page/home/home_cell_style/divider_tab_bar.dart new file mode 100644 index 0000000..4e7d9f1 --- /dev/null +++ b/lib/hj_page/home/home_cell_style/divider_tab_bar.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +/// 小字筛选栏:文字 + 竖线分隔,用在标签/专题/搜索结果/短剧等列表页顶部 +class DividerTabBar extends StatelessWidget { + final List titles; + final int selectIndex; + final Function(int)? callback; + final AlignmentGeometry? alignment; + + const DividerTabBar( + this.titles, { + super.key, + this.selectIndex = 0, + this.callback, + this.alignment, + }); + + @override + Widget build(BuildContext context) { + return Container( + alignment: alignment ?? Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < titles.length; i++) _buildMenuButton(i), + ], + ), + ); + } + + Widget _buildMenuButton(int index) { + final isLast = index == titles.length - 1; + return InkWell( + enableFeedback: false, + onTap: () => callback?.call(index), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + titles[index], + style: TextStyle( + fontSize: 16.sp, + color: index == selectIndex ? const Color(0xE5FFFFFF) : const Color(0x73FFFFFF), + ), + ), + //分隔竖线跟在每项后面,最后一项不带 + if (!isLast) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + "|", + style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.05)), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/home/home_cell_style/guess_like_sliver.dart b/lib/hj_page/home/home_cell_style/guess_like_sliver.dart new file mode 100644 index 0000000..e58d46c --- /dev/null +++ b/lib/hj_page/home/home_cell_style/guess_like_sliver.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; + +import '../../../hj_model/home/module_detail_model.dart'; +import '../../../hj_utils/sliver_delegate.dart'; +import '../home_sub_module/home_tab_section_logic.dart'; +import '../widget/home_sort_menu_view.dart'; +import 'video_simple_cell.dart'; + +/// 亚模块底部的「猜你喜欢」:标题 + 吸顶排序栏 + 两列网格 +class GuessLikeSliver extends StatelessWidget { + final AllSection model; + final int? sortIndex; + final HomeTabSectionLogic? logic; + + const GuessLikeSliver(this.model, {super.key, this.sortIndex, this.logic}); + + @override + Widget build(BuildContext context) { + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Container( + padding: EdgeInsets.only(left: 12), + child: Text( + "猜你喜欢", + style: TextStyle( + fontSize: 18, + color: Color(0xE5FFFFFF), + fontWeight: FontWeight.w600, + ), + ), + ), + ), + SliverPersistentHeader( + pinned: true, + floating: true, + delegate: MySliverDelegate( + maxHeight: 42, + minHeight: 42, + childBuildHandler: (_, offset, overlaps, child) { + return Container( + color: + (offset >= 0) ? AppColors.primaryColor : Colors.transparent, + child: child, + ); + }, + child: Container( + alignment: Alignment.centerLeft, + padding: EdgeInsets.only(left: 12), + child: HomeSortMenuView( + ["最多收藏", "最新上架", "最多观看"], + gapChar: "|", + selectIndex: sortIndex ?? 0, + callback: (index) { + if (index != sortIndex) { + logic?.sortGuessLikeExchange(index); + } + }, + ), + ), + ), + ), + if (model.allVideoInfo == null) + SliverToBoxAdapter( + child: SizedBox(height: 300, child: LoadingCenterWidget()), + ) + else if (model.allVideoInfo!.isEmpty) + SliverToBoxAdapter( + child: SizedBox(height: 300, child: CErrorWidget()), + ) + else + SliverPadding( + padding: EdgeInsets.only(left: 12, right: 12), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 8, + childAspectRatio: 168 / 154, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => + VideoSimpleCell(videoModel: model.allVideoInfo![index]), + childCount: model.allVideoInfo!.length, + ), + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/home/home_cell_style/home_section_cell.dart b/lib/hj_page/home/home_cell_style/home_section_cell.dart new file mode 100644 index 0000000..da8a6ae --- /dev/null +++ b/lib/hj_page/home/home_cell_style/home_section_cell.dart @@ -0,0 +1,490 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../hj_model/home/module_detail_model.dart'; +import '../../../hj_utils/api_service/vid_service.dart'; +import '../../../tools_base/debug_log.dart'; +import '../special_topic_detail/special_topics_detail_page.dart'; +import 'home_section_title.dart'; +import 'video_hor_cell.dart'; +import 'video_simple_cell.dart'; + +// OneLargeAndFourSmall = 101 or 0 // 0 一大四小 +// HScroll15 = 104 // 1 1.5左右滑动(横) +// HScroll25 = 105 // 2 2.5左右滑动(横) +// FourGrid = 102 // 3 四宫格(横acg+影视共用) +// SixGrid = 103 // 4 六宫格(横) +// HListGrid = 106 // 5 小列表 (横) +// HListBigGrid = 107 // 6 大列表 (横) +// VerticalScroll25 = 205 // 7 2.5左右滑动(竖ACG) +// VerticalFourGrid = 201 // 8 四宫格(竖ACG) +// VerticalSixGrid = 202 // 9 六宫格(竖ACG) +// VerticalNineGrid = 203 // 10 九宫格(竖ACG) +// VerticalListGrid = 204 // 11 小列表 (竖ACG) +// GuessYouLike = 301 // 12 猜你喜欢 +enum HomeSectionShowType { + oneLargeAndFourSmall(101, canExchange: true), + hScroll15(104), + hScroll25(105), + fourGrid(102, canExchange: true), + sixGrid(103, canExchange: true), + hListGrid(106), + hListBigGrid(107), + verticalScroll25(205), + verticalFourGrid(201), + verticalSixGrid(202), + verticalNineGrid(203), + verticalListGrid(204), + guessYouLike(301), + ; + + final int value; + final bool canExchange; //底部是否挂「更多片源 / 更换一批」 + + const HomeSectionShowType(this.value, {this.canExchange = false}); +} + +//首页专题 section:按 showType 排版的内容 + (部分样式)底部换一批 + 间距分割线 +class HomeSectionCell extends StatefulWidget { + final AllSection section; + + const HomeSectionCell(this.section, {super.key}); + + @override + State createState() => _HomeSectionCellState(); +} + +class _HomeSectionCellState extends State { + //value→枚举 O(1) 查找,避免每次 build 线性遍历 values + static final Map _typeMap = { + for (final t in HomeSectionShowType.values) t.value: t, + }; + + AllSection get section => widget.section; + + bool _exchanging = false; //换一批请求中 + + @override + Widget build(BuildContext context) { + //未知 showType 排版兜底一大四小,但不挂换一批(与原先按 101/102/103 判断保持一致) + final style = _typeMap[section.showType]; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _body(style), + //101/102/103 底部挂「更多片源 / 更换一批」,其余样式只留间距 + if (style?.canExchange == true) + Padding( + padding: const EdgeInsets.only(bottom: 24), child: _exchangeBar()) + else + 12.sizeBoxH, + //section 间的分割线跟着 cell 走,外面只管往列表里塞 + Container( + margin: const EdgeInsets.only(bottom: 12, left: 16, right: 16), + height: 1, + color: const Color(0x19FFFFFF), + ), + ], + ); + } + + Widget _body(HomeSectionShowType? style) { + switch (style) { + case HomeSectionShowType.hScroll15: + return SectionOneHalfFiveHor(section); + case HomeSectionShowType.hScroll25: + return SectionTwoHalfFiveHV(section); + case HomeSectionShowType.fourGrid: + return SectionGridViewHV(section, maxCount: 4); + case HomeSectionShowType.sixGrid: + return SectionGridViewHV(section, maxCount: 6); + case HomeSectionShowType.hListGrid: + return SectionListView(section); + case HomeSectionShowType.hListBigGrid: + return SectionListView(section, isBigStyle: true); + case HomeSectionShowType.verticalScroll25: + return SectionTwoHalfFiveHV(section, isVertical: true); + case HomeSectionShowType.verticalFourGrid: + return SectionGridViewHV( + section, + maxCount: 4, + aspectRatio: 168 / 266, + textLines: 1, + ); + case HomeSectionShowType.verticalSixGrid: + return SectionGridViewHV( + section, + maxCount: 6, + aspectRatio: 111 / 190, + crossAxisCount: 3, + textLines: 1, + ); + case HomeSectionShowType.verticalNineGrid: + return SectionGridViewHV( + section, + maxCount: 9, + aspectRatio: 111 / 190, + crossAxisCount: 3, + textLines: 1, + ); + case HomeSectionShowType.verticalListGrid: + return SectionGridViewSmall(section); + default: + return SectionOneBigFourSmall(section); + } + } + + //底部两颗胶囊:更多片源(跳专题详情) / 更换一批(换数据) + Widget _exchangeBar() { + return Container( + height: 36, + margin: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Row( + children: [ + Expanded( + child: _ExchangeButton( + title: '更多片源', + onTap: () => Get.to(SpecialTopicsDetailPage(section), + preventDuplicates: false), + ), + ), + 16.sizeBoxW, + Expanded( + child: _ExchangeButton( + title: '更换一批', loading: _exchanging, onTap: _exchange), + ), + ], + ), + ); + } + + //换一批:只替换本 section 的视频(仅重建本 cell),失败保持原数据不动 + Future _exchange() async { + if (_exchanging) return; + final id = section.sectionID; + setState(() => _exchanging = true); + try { + final resp = await VidService.sectionVideoExchange(id); + //请求飞行中列表可能被刷新,cell 按 index 复用后 section 已换人,结果不能再往回写 + if (resp.videos?.isNotEmpty == true && section.sectionID == id) + section.allVideoInfo = resp.videos; + } catch (e) { + debugLog(e); + } + if (mounted) setState(() => _exchanging = false); + } +} + +//「更多片源 / 更换一批」胶囊按钮 +class _ExchangeButton extends StatelessWidget { + final String title; + final bool loading; + final VoidCallback onTap; + + const _ExchangeButton( + {required this.title, required this.onTap, this.loading = false}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: const Color(0x1AFFFFFF), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + //转圈占位 16、常态 4,保持文字位置不跳 + if (loading) + const SizedBox( + width: 16, + height: 16, + child: CupertinoActivityIndicator( + radius: 8, color: Colors.white)) + else + const SizedBox(width: 4), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 14, + fontWeight: FontWeight.w400), + ), + ], + ), + ), + ); + } +} + +//一大四小 +class SectionOneBigFourSmall extends StatelessWidget { + final AllSection section; + + const SectionOneBigFourSmall(this.section, {super.key}); + + @override + Widget build(BuildContext context) { + if (section.allVideoInfo?.isNotEmpty != true) + return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HomeSectionTitle(section), + Container( + height: 235, + padding: EdgeInsets.symmetric(horizontal: 12), + child: VideoSimpleCell( + videoModel: section.allVideoInfo!.first, + textLines: 1, + ), + ), + 12.sizeBoxH, + GridView.builder( + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 12), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: 168 / 154, + ), + itemCount: min(4, section.allVideoInfo!.length - 1), + itemBuilder: (context, index) { + VideoModel model = section.allVideoInfo![index + 1]; + return VideoSimpleCell(videoModel: model); + }, + ), + ], + ); + } +} + +//1.5横 +class SectionOneHalfFiveHor extends StatelessWidget { + final AllSection section; + + const SectionOneHalfFiveHor(this.section, {super.key}); + + @override + Widget build(BuildContext context) { + if (section.allVideoInfo?.isNotEmpty != true) + return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HomeSectionTitle(section), + Container( + height: 202, + padding: EdgeInsets.only(left: 12), + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: section.allVideoInfo!.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + VideoModel model = section.allVideoInfo![index]; + return Container( + height: 202, + width: 280, + margin: EdgeInsets.only(right: 8), + child: VideoSimpleCell(videoModel: model, textLines: 1), + ); + }, + ), + ) + ], + ); + } +} + +//2.5横/竖 +class SectionTwoHalfFiveHV extends StatelessWidget { + final AllSection section; + final bool isVertical; // true 竖, false 横 + const SectionTwoHalfFiveHV(this.section, + {super.key, this.isVertical = false}); + + @override + Widget build(BuildContext context) { + if (section.allVideoInfo?.isNotEmpty != true) + return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HomeSectionTitle(section), + Container( + height: isVertical ? 218 : 146, + padding: EdgeInsets.only(left: 12), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final width = (constraints.maxWidth - 2 * 8) / 2.5; + return SizedBox( + height: isVertical ? 218 : 146, + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: section.allVideoInfo!.length, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) { + VideoModel model = section.allVideoInfo![index]; + return Container( + height: 146, + width: width, + margin: EdgeInsets.only(right: 8), + child: VideoSimpleCell( + videoModel: model, + textLines: isVertical ? 1 : 2, + ), + ); + }, + ), + ); + }, + ), + ) + ], + ); + } +} + +//四/六/九宫格横/竖 (默认四宫格) +class SectionGridViewHV extends StatelessWidget { + final AllSection section; + final int maxCount; + final double aspectRatio; + final int crossAxisCount; + final int textLines; + + const SectionGridViewHV( + this.section, { + super.key, + this.maxCount = 4, + this.aspectRatio = 168 / 154, + this.crossAxisCount = 2, + this.textLines = 2, + }); + + @override + Widget build(BuildContext context) { + if (section.allVideoInfo?.isNotEmpty != true) + return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HomeSectionTitle(section), + GridView.builder( + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 12), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: aspectRatio, + ), + itemCount: min(maxCount, section.allVideoInfo!.length), + itemBuilder: (context, index) { + VideoModel model = section.allVideoInfo![index]; + return VideoSimpleCell(videoModel: model, textLines: textLines); + }, + ), + ], + ); + } +} + +//列表横(大小横) +class SectionListView extends StatelessWidget { + final AllSection section; + final bool isBigStyle; // true 大横, false 小横 + const SectionListView(this.section, {super.key, this.isBigStyle = false}); + + @override + Widget build(BuildContext context) { + if (section.allVideoInfo?.isNotEmpty != true) + return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HomeSectionTitle(section), + ListView.separated( + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 12), + shrinkWrap: true, + itemCount: min(3, section.allVideoInfo!.length), + separatorBuilder: (context, index) => 12.sizeBoxH, + itemBuilder: (context, index) { + VideoModel model = section.allVideoInfo![index]; + if (isBigStyle) { + return SizedBox( + height: 230, + child: VideoSimpleCell(videoModel: model, textLines: 1), + ); + } else { + return SizedBox( + height: 96, + child: VideoHorCell(videoModel: model), + ); + } + }, + ), + ], + ); + } +} + +//小列表(竖) +class SectionGridViewSmall extends StatelessWidget { + final AllSection section; + + const SectionGridViewSmall(this.section, {super.key}); + + @override + Widget build(BuildContext context) { + if (section.allVideoInfo?.isNotEmpty != true) + return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + HomeSectionTitle(section), + Container( + padding: EdgeInsets.only(left: 12), + height: 232, + child: GridView.builder( + padding: EdgeInsets.zero, + scrollDirection: Axis.horizontal, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 16, + crossAxisSpacing: 8, + childAspectRatio: 110 / 256, + ), + itemCount: section.allVideoInfo!.length, + itemBuilder: (context, index) { + VideoModel model = section.allVideoInfo![index]; + return VideoHorCell( + videoModel: model, + imageWidth: 82, + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/home/home_cell_style/home_section_title.dart b/lib/hj_page/home/home_cell_style/home_section_title.dart new file mode 100644 index 0000000..cf9bf21 --- /dev/null +++ b/lib/hj_page/home/home_cell_style/home_section_title.dart @@ -0,0 +1,42 @@ +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../hj_model/home/module_detail_model.dart'; +import '../special_topic_detail/special_topics_detail_page.dart'; + +/// 专题标题行:标题 + 右箭头,点整行进专题详情 +class HomeSectionTitle extends StatelessWidget { + final AllSection? section; + + const HomeSectionTitle(this.section, {super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.fromLTRB(12, 0, 12, 12), + child: GestureDetector( + onTap: () => + Get.to(SpecialTopicsDetailPage(section), preventDuplicates: false), + child: Row( + children: [ + Expanded( + child: Text( + section?.sectionName ?? "", + maxLines: 1, + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + 12.sizeBoxW, + Image.asset("arrow_right_grey.webp".commonImgPath, width: 18), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/home/home_cell_style/level_mark_chip.dart b/lib/hj_page/home/home_cell_style/level_mark_chip.dart new file mode 100644 index 0000000..812cac8 --- /dev/null +++ b/lib/hj_page/home/home_cell_style/level_mark_chip.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../config/config.dart'; +import '../../../hj_model/video_model.dart'; +import '../../../hj_utils/free_play_manager.dart'; + +/// 封面右上角的售卖角标:免费试看 / 金币 / VIP,三者互斥,都不满足就不占位。 +/// 展不展示还受 ping 下发的 freeMark / coinMark / vipMark 三个开关控制 +class LevelMarkChip extends StatelessWidget { + final VideoModel? videoModel; + + const LevelMarkChip(this.videoModel, {super.key}); + + @override + Widget build(BuildContext context) { + if (videoModel?.freeArea == true) return const SizedBox(); + // 免费试看:ping 免费类角标总开关 freeMark + 实时试看权益(次数用尽即不再展示) + if (Config.freeMark && FreePlayManager().canShowFreeTrialBadge(videoModel)) { + return _chip("免费试看", const Color(0xff141414), const [Color(0xff35DEBC), Color(0xff22BB9C)]); + } + final isCoin = videoModel?.isCoinVideo() == true; + if (isCoin && Config.coinMark) { + return _chip("金币", const Color(0xff8B3E00), const [Color(0xffFFE580), Color(0xffFACC15)]); + } + if (!isCoin && Config.vipMark) { + return _chip("VIP", const Color(0xff141414), const [Color(0xffF8F3AE), Color(0xffDDAC44)]); + } + return const SizedBox(); + } + + Widget _chip(String text, Color textColor, List gradientColors) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + gradient: LinearGradient(colors: gradientColors), + ), + child: Text( + text, + style: TextStyle(color: textColor, fontSize: 10, height: 1.6), + ), + ); + } +} diff --git a/lib/hj_page/home/home_cell_style/quick_entry_row.dart b/lib/hj_page/home/home_cell_style/quick_entry_row.dart new file mode 100644 index 0000000..dad31a2 --- /dev/null +++ b/lib/hj_page/home/home_cell_style/quick_entry_row.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../hj_model/home/plate_model.dart'; +import '../../../hj_model/splash/domain_source_model.dart'; + +/// 金刚区:亚模块顶部那排图标入口,横向可滑,一屏露 5.5 个。 +/// 配置来自 /domain 下发的 jgArea,按 mid == 亚模块 id 匹配;没配就整块不占位 +class QuickEntryRow extends StatelessWidget { + final ModuleData? tabModel; + const QuickEntryRow(this.tabModel, {super.key}); + + @override + Widget build(BuildContext context) { + final dataArr = + (Config.jgArea ?? []).where((e) => e.mid == tabModel?.id).toList(); + if (dataArr.isEmpty) return const SizedBox.shrink(); + return Container( + height: 62, + margin: const EdgeInsets.fromLTRB(12, 0, 0, 12), + child: LayoutBuilder( + builder: (context, constraints) { + final width = (constraints.maxWidth - 5 * 16) / 5.5; + final height = width * 48 / 58; + return SizedBox( + height: height, + child: ListView.builder( + itemCount: dataArr.length, + padding: EdgeInsets.zero, + scrollDirection: Axis.horizontal, + itemBuilder: (context, index) => + _buildItem(dataArr[index], width), + ), + ); + }, + ), + ); + } + + Widget _buildItem(JGAreaModel model, double width) { + return Container( + height: 62, + width: width, + margin: const EdgeInsets.only(right: 16), + child: InkWell( + enableFeedback: false, + onTap: () => pushToPageByLink(model.linkUrl), + child: Column( + children: [ + Expanded( + child: Container( + alignment: Alignment.center, + child: AspectRatio( + aspectRatio: 1, + child: NetworkImageLoader(imageUrl: model.img ?? ""), + ), + ), + ), + 3.sizeBoxH, + Text( + model.name ?? "", + maxLines: 1, + style: const TextStyle(color: Colors.white, fontSize: 11), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/home/home_cell_style/tiktok_simple_cell.dart b/lib/hj_page/home/home_cell_style/tiktok_simple_cell.dart new file mode 100644 index 0000000..2247160 --- /dev/null +++ b/lib/hj_page/home/home_cell_style/tiktok_simple_cell.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; + +import '../../../hj_model/video_model.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import 'package:hgdj/extension/extensions.dart'; + +/// 抖音样式的视频 item:封面 + 底部角标 + 标题 +(可选)标签/评论数。 +/// 封面占满父级剩余高度,外层必须给约束(网格 / 固定高容器) +class TiktokSimpleCell extends StatelessWidget { + final VideoModel? videoModel; + final int textLines; + final bool isShowBottom; // true 显示底部标签和评论数 + final bool isShowTime; // true 封面右下角显示时长,false 显示图集数 + + const TiktokSimpleCell({ + super.key, + this.videoModel, + this.textLines = 2, + this.isShowTime = false, + this.isShowBottom = true, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Expanded(child: _buildCover()), + Container( + padding: const EdgeInsets.only(top: 4), + //末尾补个换行,让不满两行的标题也占满高度,卡片不会参差 + child: Text( + "${videoModel?.title ?? ""}\n", + maxLines: textLines, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), fontSize: 12), + ), + ), + if (isShowBottom) _buildBottom(), + ], + ), + ); + } + + void _onTap() { + if (videoModel?.isRandomAd() == true) { + pushToPageByLink(videoModel?.randomAdsInfo?.href); + return; + } + pushToVideoPage(videoModel: videoModel); + } + + //封面:图 + 底部渐变角标条 + Widget _buildCover() { + return Stack( + fit: StackFit.expand, + children: [ + NetworkImageLoader(imageUrl: videoModel?.cover ?? "", borderRadius: 4), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + borderRadius: + const BorderRadius.vertical(bottom: Radius.circular(4)), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0), + Colors.black.withValues(alpha: 0.6) + ], + ), + ), + child: _buildCoverBadge(), + ), + ), + ], + ); + } + + //角标条:左播放数,右边给时长或图集数 + Widget _buildCoverBadge() { + return Row( + children: [ + Image.asset("eye_white.webp".commonImgPath, width: 16), + 3.sizeBoxW, + Text( + videoModel?.playCount?.countStr ?? "0", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(color: Colors.white, fontSize: 10, height: 1.6), + ), + const Spacer(), + if (isShowTime) + Text( + videoModel?.playTime?.hmsStr ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 10), + ) + else ...[ + Image.asset("tuji_icon.webp".communityPath, width: 16), + 2.sizeBoxW, + Text( + '${videoModel?.seriesCover?.length ?? 0}', + textAlign: TextAlign.justify, + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + ], + 2.sizeBoxW, + ], + ); + } + + //标签 + 评论数 + Widget _buildBottom() { + final tagName = videoModel?.tags?.firstOrNull?.name ?? ""; + return Container( + padding: const EdgeInsets.only(top: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + //标签可能是很长的脏数据(id),Flexible 才能让 ellipsis 生效,否则会撑爆 Row + Flexible( + child: tagName.isEmpty + ? const SizedBox.shrink() + : Container( + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: const Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + tagName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0x73FFFFFF), fontSize: 10), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + "评论${videoModel?.commentCount?.countStr ?? 0}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0x73FFFFFF), fontSize: 10), + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/home/home_cell_style/video_hor_cell.dart b/lib/hj_page/home/home_cell_style/video_hor_cell.dart new file mode 100644 index 0000000..6d98f65 --- /dev/null +++ b/lib/hj_page/home/home_cell_style/video_hor_cell.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../hj_model/video_model.dart'; +import '../../../hj_utils/free_play_manager.dart'; +import '../../../routers/jump_router.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import 'level_mark_chip.dart'; +import 'package:hgdj/extension/extensions.dart'; + +/// 横版视频 item:左封面(带售卖角标) + 右标题/收藏数,用在专题的小列表样式 +class VideoHorCell extends StatelessWidget { + final VideoModel? videoModel; + final double imageWidth; + + const VideoHorCell({super.key, this.videoModel, this.imageWidth = 150}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + child: Row( + children: [ + Stack( + children: [ + NetworkImageLoader( + width: imageWidth, + height: double.infinity, + borderRadius: 4, + imageUrl: videoModel?.cover ?? "", + ), + Positioned( + right: 6, + top: 6, + // 监听免费次数变化:会话内试看用尽时角标实时刷新,不再残留「免费试看」 + child: ValueListenableBuilder( + valueListenable: FreePlayManager().revision, + builder: (_, __, ___) => LevelMarkChip(videoModel), + ), + ), + ], + ), + 12.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${videoModel?.title ?? ""}\n", + maxLines: 2, + style: TextStyle( + color: Colors.white, + fontSize: 12, + height: 1.2, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + 4.sizeBoxH, + Text( + "收藏:${videoModel?.collectCount?.countStr ?? "0"}", + maxLines: 2, + style: TextStyle( + color: Color(0x73FFFFFF), + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ); + } + + void _onTap() { + if (videoModel?.isRandomAd() == true) { + pushToPageByLink(videoModel?.randomAdsInfo?.href); + return; + } + pushToVideoPage(videoModel: videoModel); + } +} diff --git a/lib/hj_page/home/home_cell_style/video_simple_cell.dart b/lib/hj_page/home/home_cell_style/video_simple_cell.dart new file mode 100644 index 0000000..6db4d8d --- /dev/null +++ b/lib/hj_page/home/home_cell_style/video_simple_cell.dart @@ -0,0 +1,471 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/banner/ads_item.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +import '../../../hj_model/splash/ads_model.dart'; +import '../../../hj_model/video_model.dart'; +import '../../../hj_utils/free_play_manager.dart'; +import '../../../tools_base/banner/ads_banner_widget.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import 'level_mark_chip.dart'; + +/// 竖版视频 item:封面(角标/售卖标/暗网遮罩) + 标题 +(可选)标签/评论数。 +/// 命中广告数据时整体换成 [VideoSimpleAdsCell] +class VideoSimpleCell extends StatelessWidget { + final VideoModel? videoModel; + final BorderRadius? imgBorderRadius; + final GestureTapCallback? onTap; + final int textLines; + final bool isFromHY; // 黄油风格 + final bool isShowBottom; // true 显示底部标签和评论数 + final bool isFromSearch; + final double titleFontSize; // 标题字号,小卡场景传小值 + final double coverFontSize; // 封面底部播放量/时长字号,小卡场景传小值避免溢出 + final String? coverRightText; // 封面右下角文案,默认时长;短剧传「共N集」 + final bool showLevelIcon; // 封面右上角「金币/VIP/免费试看」角标;短剧按集卖,整部剧标一个对不上,传 false + final ValueChanged? onTagTap; // 左下角标签点击;短剧跳标签详情 + + const VideoSimpleCell({ + super.key, + this.videoModel, + this.imgBorderRadius, + this.textLines = 2, + this.onTap, + this.isFromHY = false, + this.isShowBottom = true, + this.isFromSearch = false, + this.titleFontSize = 12, + this.coverFontSize = 10, + this.coverRightText, + this.showLevelIcon = true, + this.onTagTap, + }); + + // 暗网风格遮罩:搜索来源且命中暗标签 + bool get isDarkStyle => isFromSearch && (videoModel?.isDarkTag ?? false); + + // 暗网内容且非 VIP,需引导开通才能观看 + bool get _needVipUnlock => isDarkStyle && !globalStore.isAWVIP; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap ?? _onTap, + child: videoModel?.isAdsArr() == true + ? VideoSimpleAdsCell( + videoModel: videoModel, + textLines: textLines, + ) + : _buildContent(), + ); + } + + void _onTap() { + if (videoModel?.isRandomAd() == true) { + pushToPageByLink(videoModel?.randomAdsInfo?.href); + return; + } + if (_needVipUnlock) { + pushToWalletPage(vipId: Config.darkWebVipId); + return; + } + pushToVideoPage(videoModel: videoModel); + } + + Widget _buildContent() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Expanded(child: _buildCoverItem()), + Container( + padding: const EdgeInsets.only(top: 4), + //末尾补个换行,让不满两行的标题也占满高度,卡片不会参差 + child: Text( + "${videoModel?.title ?? ""}\n", + maxLines: textLines, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: titleFontSize), + ), + ), + if (isShowBottom) _buildBottomWidget(), + ], + ); + } + + Widget _buildCoverItem() { + return Stack( + fit: StackFit.expand, + children: [ + NetworkImageLoader( + imageUrl: videoModel?.cover ?? "", + imgBorderRadius: imgBorderRadius, + borderRadius: 4, + blur: _needVipUnlock, + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: 24, + padding: EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical(bottom: Radius.circular(4)), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0), + Colors.black.withValues(alpha: 0.6), + ], + ), + ), + child: isFromHY ? _buildCoverHYBottom() : _buildCoverNormalBottom(), + ), + ), + if (showLevelIcon) + Positioned( + right: 6, + top: 6, + // 监听免费次数变化:会话内试看用尽时角标实时刷新,不再残留「免费试看」 + child: ValueListenableBuilder( + valueListenable: FreePlayManager().revision, + builder: (_, __, ___) => LevelMarkChip(videoModel), + ), + ), + if (_needVipUnlock) + Container( + color: Colors.black.withValues(alpha: 0.1), + alignment: Alignment.center, + padding: EdgeInsets.only(bottom: 8), + child: EasyRichText( + '需要开通 ${Config.darkWebVipName}\n即可观看', + textAlign: TextAlign.center, + defaultStyle: TextStyle( + fontSize: 10, + color: Colors.white, + height: 1.6, + ), + patternList: [ + EasyRichTextPattern( + targetString: Config.darkWebVipName, + style: TextStyle(color: Color(0xffF68804)), + ), + ], + ), + ), + ], + ); + } + + Widget _buildCoverNormalBottom() { + return Row( + children: [ + Image.asset('circle_play.webp'.videoPath, width: 16), + 1.sizeBoxW, + Text( + videoModel?.playCount?.countStr ?? "0", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, fontSize: coverFontSize, height: 1.6), + ), + Spacer(), + Text( + coverRightText ?? videoModel?.playTime?.hmsStr ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: coverFontSize, + ), + ), + ], + ); + } + + Widget _buildCoverHYBottom() { + return Row( + children: [ + Image.asset("eye_white.webp".commonImgPath, width: 16), + 1.sizeBoxW, + Text( + videoModel?.pageViewCount?.countStr ?? "0", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white, fontSize: 10, height: 1.6), + ), + Spacer(), + ], + ); + } + + Widget _buildBottomWidget() { + if (isFromHY) { + final tagDesc = + videoModel?.tags?.map((e) => e.name ?? "").join("·") ?? ""; + return Container( + padding: EdgeInsets.only(top: 3), + child: Text( + tagDesc, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Color(0x73FFFFFF), + fontSize: 10, + ), + ), + ); + } else { + return Container( + padding: EdgeInsets.only(top: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + //标签可能是很长的脏数据(id),Flexible 才能让 ellipsis 生效,否则会撑爆 Row + Flexible( + child: videoModel?.tags?.isNotEmpty == true + ? GestureDetector( + onTap: onTagTap == null + ? null + : () => onTagTap!(videoModel!.tags!.first), + child: Container( + padding: + EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + videoModel?.tags?.first.name ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Color(0x73FFFFFF), + fontSize: 10, + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + "评论${videoModel?.commentCount?.countStr ?? 0}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Color(0x73FFFFFF), + fontSize: 10, + ), + ), + ), + ], + ), + ); + } + } +} + +//视频穿插广告 +class VideoSimpleAdsCell extends StatefulWidget { + final VideoModel? videoModel; + final int textLines; + + const VideoSimpleAdsCell({ + super.key, + this.videoModel, + this.textLines = 2, + }); + + @override + State createState() => _VideoSimpleAdsCellState(); +} + +class _VideoSimpleAdsCellState extends State { + VideoModel? get videoModel => widget.videoModel; + + int adIndex = 0; + + List? get adsInfoArr => + videoModel?.adsInfoArr; //?? mediaInfo?.adsInfoArr; + + AdsInfoModel? get adsModel => videoModel?.randomAdsInfo; + + // 广告点击:跳落地页 + void _onAdTap() { + final ad = adsInfoArr![adIndex]; + pushToPageByLink(ad.href); + } + + @override + Widget build(BuildContext context) { + if (adsModel != null) { + return _buildSimpleAD(adsModel); + } else if (adsInfoArr?.isNotEmpty == true) { + return _buildMuliAds(); + } else { + return SizedBox(); + } + } + + Widget _buildMuliAds() { + return InkWell( + enableFeedback: false, + onTap: _onAdTap, + child: LayoutBuilder( + builder: (ctx, constraints) { + // isHor true: 大横屏广告 + bool isHor = (screen.screenWidth - 36) <= constraints.maxWidth; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: AdsBannerWidget( + adsInfoArr, + autoPlayMs: 3000, + borderRadius: 4, + onIndexChanged: (index) { + adIndex = index; + if (mounted) setState(() {}); + }, + ), + ), + ), + Container( + padding: EdgeInsets.only(top: 4), + // 广告轮播切到下一条时,标题做渐入渐出切换 + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + layoutBuilder: (current, previous) => Stack( + alignment: Alignment.centerLeft, + children: [...previous, if (current != null) current], + ), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + child: Text( + isHor + ? adsInfoArr![adIndex].title?.trim() ?? "" + : "${adsInfoArr![adIndex].title?.trim() ?? ""}\n", + key: ValueKey(adIndex), // adIndex 变化触发切换动画 + maxLines: widget.textLines, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ), + if (!isHor) + Container( + padding: const EdgeInsets.only(top: 3), + child: Text( + "", + style: TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _buildSimpleAD(AdsInfoModel? adModel) { + return InkWell( + enableFeedback: false, + onTap: _onAdTap, + child: LayoutBuilder( + builder: (ctx, constraints) { + // isHor true: 大横屏广告 + bool isHor = (screen.screenWidth - 32) <= constraints.maxWidth; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + AdsItem( + adInfo: adModel!, + borderRadius: 4, + showType: AdShowType.img, + ), + Positioned( + top: 0, + left: 0, + child: Container( + width: 34, + height: 16, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: LinearGradient(colors: [ + Color.fromRGBO(255, 235, 58, 1), + Color.fromRGBO(255, 235, 58, 1), + ]), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(4), + )), + child: Text( + "广告", + style: TextStyle( + fontSize: 11, + color: Color.fromRGBO(0, 0, 0, 1), + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.only(top: 4), + child: Text( + isHor + ? videoModel?.title ?? "" + : "${videoModel?.title ?? ""}\n", + maxLines: widget.textLines, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + if (!isHor) + Container( + padding: const EdgeInsets.only(top: 3), + child: Text( + "", + style: TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/hj_page/home/home_drawer.dart b/lib/hj_page/home/home_drawer.dart new file mode 100644 index 0000000..89e352f --- /dev/null +++ b/lib/hj_page/home/home_drawer.dart @@ -0,0 +1,187 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; + +import '../../hj_model/home/plate_model.dart'; +import '../../hj_utils/screen.dart'; +import 'home_drawer_logic.dart'; +import 'home_main_logic.dart'; + +class HSHomeDrawer extends StatelessWidget { + final HomeMainLogic homeLogic; + const HSHomeDrawer({super.key, required this.homeLogic}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: HomeDrawerLogic(homeLogic), + builder: (logic) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => logic.goBack(), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: SizedBox( + width: screen.screenWidth, + height: screen.screenHeight, + child: Align( + alignment: Alignment.topRight, + child: Container( + width: 212, + height: screen.screenHeight, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 18), + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration(color: Colors.black), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 32.sizeBoxH, + _headerRow(logic), + 4.sizeBoxH, + Text( + '长按拖动排序', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.45), + fontSize: 12, + ), + ), + 18.sizeBoxH, + Expanded(child: _buildGridContent(logic)), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } + + Widget _headerRow(HomeDrawerLogic logic) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '导航列表', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + InkWell(enableFeedback: false, + onTap: logic.resetToDefault, + child: Container( + padding: const EdgeInsets.fromLTRB(6, 2, 0, 6), + child: Text( + '恢复默认', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.45), + fontSize: 12, + ), + ), + ), + ), + ], + ); + } + + //抽屉列表:Stack + AnimatedPositioned,排序变化时 item 平滑滑到新位置 + Widget _buildGridContent(HomeDrawerLogic logic) { + const cross = 2; // 列数 + const spacing = 12.0; // 行列间距 + const ratio = 84 / 40; // item 宽高比 + return LayoutBuilder( + builder: (context, constraints) { + final itemW = (constraints.maxWidth - spacing * (cross - 1)) / cross; + final itemH = itemW / ratio; + final rows = (logic.dataArr.length / cross).ceil(); + final totalH = rows * itemH + (rows > 0 ? rows - 1 : 0) * spacing; + return SingleChildScrollView( + child: SizedBox( + height: totalH, + child: Stack( + children: [ + for (int i = 0; i < logic.dataArr.length; i++) _positionedItem(logic, i, itemW, itemH, spacing, cross), + ], + ), + ), + ); + }, + ); + } + + //单个 item 的定位 + 交换补间动画(key 用 id 保证身份稳定,位置变化才会补间) + Widget _positionedItem( + HomeDrawerLogic logic, + int index, + double w, + double h, + double spacing, + int cross, + ) { + final tabModel = logic.dataArr[index]; + final isSelected = logic.curTagData?.id == tabModel.id; + final row = index ~/ cross; + final col = index % cross; + return AnimatedPositioned( + key: ValueKey(tabModel.id), + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + left: col * (w + spacing), + top: row * (h + spacing), + width: w, + height: h, + child: _buildDraggableItem(logic, tabModel, isSelected, w, h), + ); + } + + Widget _buildDraggableItem(HomeDrawerLogic logic, ModuleData tabModel, bool isSelected, double w, double h) { + return DragTarget( + onAcceptWithDetails: (details) => logic.reorderItem(details.data, tabModel), + builder: (context, candidateData, rejectedData) { + final itemBody = Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: isSelected ? const Color(0xfff68804) : const Color(0x1AFFFFFF), + ), + child: GestureDetector( + onTap: () => logic.goBack(selectedModel: tabModel), + child: Text( + tabModel.moduleName ?? '', + style: const TextStyle(fontSize: 14, color: Colors.white), + ), + ), + ); + return LongPressDraggable( + data: tabModel, + hapticFeedbackOnStart: false, // 关掉默认轻震,用下面更明显的震动 + onDragStarted: () => HapticFeedback.mediumImpact(), // 长按触发时震一下 + feedback: Material( + color: Colors.transparent, + child: Container( + width: w, + height: h, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: const Color(0xffF68804), // 主题色(拖起态不透明) + ), + child: Text( + tabModel.moduleName ?? '', + style: const TextStyle(fontSize: 12, color: Colors.white), + ), + ), + ), + childWhenDragging: Opacity(opacity: 0.4, child: itemBody), // 占位:半透明 + child: itemBody, + ); + }, + ); + } +} diff --git a/lib/hj_page/home/home_drawer_logic.dart b/lib/hj_page/home/home_drawer_logic.dart new file mode 100644 index 0000000..372c0f0 --- /dev/null +++ b/lib/hj_page/home/home_drawer_logic.dart @@ -0,0 +1,40 @@ +import 'package:get/get.dart'; + +import '../../hj_model/home/plate_model.dart'; +import 'home_main_logic.dart'; + +class HomeDrawerLogic extends GetxController { + final HomeMainLogic homeLogic; + HomeDrawerLogic(this.homeLogic); + + ModuleData? curTagData; + List dataArr = []; + + @override + void onInit() { + super.onInit(); + curTagData = homeLogic.tabs[homeLogic.tabCtr?.index ?? 0]; + dataArr = homeLogic.tabs.map((e) => e).toList(); + } + + void goBack({ModuleData? selectedModel}) async { + await homeLogic.resortTabs(dataArr, selectedModel); + Get.back(); + } + + //恢复默认排序 + void resetToDefault() { + dataArr = homeLogic.defaultList.map((e) => e).toList(); + update(); + goBack(); + } + + //拖拽换位 + void reorderItem(ModuleData from, ModuleData to) { + final oldIndex = dataArr.indexOf(from); + final newIndex = dataArr.indexOf(to); + final item = dataArr.removeAt(oldIndex); + dataArr.insert(newIndex, item); + update(); + } +} diff --git a/lib/hj_page/home/home_main_logic.dart b/lib/hj_page/home/home_main_logic.dart new file mode 100644 index 0000000..a7bd14c --- /dev/null +++ b/lib/hj_page/home/home_main_logic.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/splash/ads_model.dart'; +import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/alert/vip_guide/guide_manager.dart'; +import 'package:hgdj/alert/vip_guide/timed_popup_manager.dart'; +import 'package:hgdj/tools_base/ad_manager.dart'; + +import '../../alert/splash/app_center_dialog.dart'; +import '../../alert/splash/notice_image_dialog.dart'; +import '../../alert/splash/notice_text_dialog.dart'; +import '../../alert/vip_guide/guide_bottom_sheet.dart'; +import '../../alert/vip_guide/guide_home_dialog.dart'; +import '../../config/config.dart'; +import '../../hj_model/home/plate_model.dart'; +import '../../tools_base/debug_log.dart'; +import '../../tools_base/module_sort_manager.dart'; +import '../mine/mine_vip/mine_charge_vip_page.dart'; +import '../mine/mine_vip/pay_order_source.dart'; +import '../mine/widgets/gradient_text.dart'; +import '../pre_sale/limit_time_provider.dart'; + +class HomeMainLogic extends GetxController { + final bool isDarkStyle; + final String? darkDefaultId; // 暗网跳转到指定的tab使用 + HomeMainLogic({ + this.isDarkStyle = false, + this.darkDefaultId, + }); + + //开屏弹窗链每次启动只跑一次 + static bool _adShown = false; + + bool isLoading = true; + int tabViewKey = 0; //排序变了就 +1 换掉 HomeTabView 的 key,强制重建 TabController + int _initIndex = 0; // 注意⚠️:暗网进入,有可能通过内链指定亚模块进入,index通过defaultId 处理 + + //合并本地排序后的 tab 列表,_loadTabs 里算一次 + List tabs = []; + + final scaffoldKey = GlobalKey(); + + //tabCtr 由 HomeTabView 在自身 vsync 下创建并 dispose,这里只持引用驱动 tab 切换;排序时置 null 触发重建 + TabController? tabCtr; + + String get _sortKey => "_localSortKey_Tab_ID_cache_$isDarkStyle"; + + int get selectedTabIndex => tabCtr?.index ?? _initIndex; + + //服务端下发的原始顺序(抽屉「恢复默认」用) + List get defaultList { + if (isDarkStyle) return Config.plateModule?.deepWeb ?? []; + // 固定最新模块 + return [ + ModuleData(id: "-110", moduleName: "最新", type: 1, showType: 1), + ...?Config.plateModule?.homePage, + ]; + } + + @override + void onInit() { + super.onInit(); + _loadTabs(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + limitTimeProvider.refresh(); //获取限时活动(放首帧后,避免 build 中 notifyListeners) + _showAdChain(); + }); + } + + void openEndDrawer() => scaffoldKey.currentState?.openEndDrawer(); + + void gotoTabId(String tagDataId) { + final index = tabs.indexWhere((e) => e.id == tagDataId); + if (index >= 0) tabCtr?.index = index; + } + + /// 应用抽屉里的新排序:顺序没变只切 tab,变了就落盘并换 key 重建 TabView + Future resortTabs(List dataArr, ModuleData? tagData) async { + final curTagData = tagData ?? tabs[selectedTabIndex]; + bool isSameArr = true; + int selectIndex = 0; + //抽屉只重排不增删,长度必然一致;逐项比名字,顺带定位选中项的新位置 + if (dataArr.length == tabs.length) { + for (int i = 0; i < dataArr.length; i++) { + if (dataArr[i].moduleName != tabs[i].moduleName) isSameArr = false; + if (dataArr[i].moduleName == curTagData.moduleName) selectIndex = i; + } + } + if (isSameArr) { + tabCtr?.index = selectIndex; + return; + } + await ModuleSortManager().saveLocalData(dataArr, _sortKey); + tabViewKey++; + tabs = dataArr; + _initIndex = selectIndex; + tabCtr = null; //旧 State 即将销毁,先断引用,由新 State 重新赋值 + update(); + } + + //合并本地排序与服务端 tab,顺带算好初始选中的 index + Future _loadTabs() async { + try { + tabs = await ModuleSortManager().mergeLocalSortWithServer( + serverTabs: defaultList, + key: _sortKey, + ); + if (darkDefaultId?.isNotEmpty == true) { + // 初始化指定模块(一般暗网模块用) + final index = tabs.indexWhere((e) => e.id == darkDefaultId); + if (index >= 0) _initIndex = index; + } else if (!isDarkStyle) { + //默认不显示最新模块,选中「热门」(index 1);但 tab 不足 2 个时回退到 0,避免越界 + _initIndex = (tabs.first.moduleName == "最新" && tabs.length > 1) ? 1 : 0; + } + } catch (e) { + debugLog(e); + } + isLoading = false; + update(); + } + + /// 首页开屏弹窗链:系统提示 → 图片公告 → 文字公告 → 预售 → 支付分层 → VIP 引导。 + /// 逐个 await 串行弹,每次启动只跑一次 + Future _showAdChain() async { + if (_adShown) return; + _adShown = true; + if (AdManager().showAbTestAd) { + await _showAppDialog(); //系统提示 + await _showImageDialog(); //图片公告 + await _showWordDialog(); //文字公告 + } + + await PreSaleProvider().showAlert(); //预售弹窗 + await _showPayDialog(); //支付分层首页弹窗 + await _showVipGuide(); //首页吸底 VIP 引导(全链优先级最后) + } + + /// 支付分层首页弹窗:有 homePage 图就弹,倒计时由 lastDiscountTime 自行判断。 + /// 点海报跳会员中心,这里 await 到用户从会员页返回,否则链条后面的吸底引导会弹在会员页上 + Future _showPayDialog() async { + final config = MineMsgProvider().payTier?.config; + if (config == null || (config.homePage ?? '').isEmpty) return; + final goVip = await Get.dialog(GuideHomeDialog(config: config)); + if (goVip != true) return; + await Get.to(() => MineChargeVipPage( + vipID: config.vipCard, sourcePage: PaySourcePage.homeUserSegment)); + } + + /// 首页吸底 VIP 引导:开屏弹窗链末尾弹(全链最低优先级),能不能弹由 HOME_NEW_USER / HOME_OLD_USER + /// 两个场景开关决定(后端按分层下发,最多命中一个);文案优先用下发的,没配走默认。 + /// 走 TimedPopupManager 与其它引导共用互斥;每次启动只弹一次(_showAdChain 由静态 _adShown 保证)。 + Future _showVipGuide() async { + final scene = GuideManager().homeScene; + if (scene == null) return; + final config = GuideManager().configOf(scene); + await TimedPopupManager().trigger( + canShow: () => GuideManager().canShow(scene), + onShow: () => GuideBottomSheet.show( + scene: scene, + title: Text( + config?.title?.isNotEmpty == true ? config!.title! : 'VIP影片免费试看', + style: const TextStyle( + color: Colors.white, fontSize: 19, fontWeight: FontWeight.w600), + ), + subtitle: GradientText( + config?.description?.isNotEmpty == true + ? config!.description! + : '开通会员 查看完整影片', + gradient: + const LinearGradient(colors: [Colors.white, Color(0xffEFD394)]), + style: const TextStyle( + color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500), + ), + ), + ); + } + + ///显示公告信息(9宫格应用广告) + Future _showAppDialog() async { + final appList = AdManager().adsByType(3); + if (appList.isEmpty) return; + await Get.dialog(AppCenterDialog(appList: appList)); + } + + ///显示图片公告(上下双排,每次弹两张) + Future _showImageDialog() async { + final adsList = AdManager().adsByType(46); + for (int i = 0; i < adsList.length; i += 2) { + await Get.dialog( + SystemImagesDialog(ads: adsList.skip(i).take(2).toList())); + } + } + + //文字公告 + Future _showWordDialog() async { + for (final wordBean in AdManager().announceList) { + await Get.dialog( + wordBean.type == 0 + ? NoticeTextDialog( + content: wordBean.content.toString(), wordBean: wordBean) + : NoticeImageDialog( + model: + AdsInfoModel(cover: wordBean.cover, href: wordBean.href)), + ); + } + } +} diff --git a/lib/hj_page/home/home_main_page.dart b/lib/hj_page/home/home_main_page.dart new file mode 100644 index 0000000..7ecce4e --- /dev/null +++ b/lib/hj_page/home/home_main_page.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/home/provider/home_update_marker_provider.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; +import 'package:provider/provider.dart'; + +import '../cartoon/cartoon_sub_detail_page.dart'; +import '../main_page/provider/bottom_bar_provider.dart'; +import 'activity_float/home_float_widget.dart'; +import 'home_drawer.dart'; +import 'home_main_logic.dart'; +import 'home_sub_module/home_tab_section_page.dart'; +import 'home_sub_module/home_tab_sort_page.dart'; +import 'search_page/widget/common_search_widget.dart'; + +class HomeMainPage extends StatelessWidget { + const HomeMainPage({super.key}); + + @override + Widget build(BuildContext context) { + return Material( + color: const Color(0xff050f17), + child: GetBuilder( + init: HomeMainLogic(), + tag: "HomeMainLogic", + builder: (logic) { + return Stack( + fit: StackFit.expand, + children: [ + Scaffold( + key: logic.scaffoldKey, + endDrawer: HSHomeDrawer(homeLogic: logic), + body: _body(logic), + ), + //右下角活动浮窗 + const Positioned(right: 12, bottom: 40, child: HomeFloatWidget()), + ], + ); + }, + ), + ); + } + + Widget _body(HomeMainLogic logic) { + if (logic.isLoading) return const LoadingCenterWidget(); + return Column( + children: [ + //顶部搜索栏(状态栏 + 导航栏高度) + Container( + color: Colors.black, + padding: EdgeInsets.only(top: screen.paddingTop), + height: kToolbarHeight + screen.paddingTop, + child: CommonSearchBarView(logic: logic), + ), + //排序变化时 tabViewKey 变,整个 TabView 连 TabController 一起重建 + Expanded( + child: HomeTabView(key: ValueKey(logic.tabViewKey), logic: logic)), + ], + ); + } +} + +class HomeTabView extends StatefulWidget { + final HomeMainLogic logic; + final bool isDarkStyle; + + const HomeTabView({ + super.key, + required this.logic, + this.isDarkStyle = false, + }); + + @override + State createState() => _HomeTabViewState(); +} + +class _HomeTabViewState extends State + with SingleTickerProviderStateMixin { + HomeMainLogic get logic => widget.logic; + bool get isDarkStyle => widget.isDarkStyle; + + late final TabController tabCtr; + + @override + void initState() { + super.initState(); + final count = logic.tabs.length; + tabCtr = TabController( + // 防止 tab 数量不足时 initialIndex 越界导致断言崩溃 + initialIndex: count > 0 ? logic.selectedTabIndex.clamp(0, count - 1) : 0, + length: count, + vsync: this, + ); + tabCtr.addListener(_onTabChange); + logic.tabCtr = tabCtr; + } + + @override + void dispose() { + // controller 在本 State vsync 创建,归本 State 释放(排序换 key 会重建 State) + tabCtr.removeListener(_onTabChange); + tabCtr.dispose(); + // 换 key 重建时新 State 可能已把自己的 ctr 写进 logic,只清掉自己那一份 + if (identical(logic.tabCtr, tabCtr)) logic.tabCtr = null; + super.dispose(); + } + + void _onTabChange() { + if (tabCtr.indexIsChanging) return; + if (isDarkStyle) { + DarkwebBottomProvider().setScrollController(tabCtr.index); + return; + } + HomeBottomProvider().setScrollController(tabCtr.index); + final tabs = logic.tabs; + if (tabCtr.index < tabs.length && tabs[tabCtr.index].id == '-110') { + // 进入「最新」:清 Tab 红点;默认排序即「今日最新」,一并视为已查看 + HomeUpdateMarkerProvider().markHomeLatestViewed(); + HomeUpdateMarkerProvider().markTodayLatestViewed(); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + height: 48.h, + color: Colors.black, + child: _tabBar(), + ), + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + if (isDarkStyle) Image.asset("aw_bg.webp".homePath), + TabBarView( + controller: tabCtr, + children: [ + for (int i = 0; i < logic.tabs.length; i++) _tabPage(i) + ], + ), + ], + ), + ), + ], + ); + } + + Widget _tabBar() { + return Consumer( + builder: (_, marker, __) { + return TabBar( + tabAlignment: TabAlignment.start, + tabs: logic.tabs.map((e) { + //「最新」有更新时右上角挂红点 + final showDot = + !isDarkStyle && e.id == '-110' && marker.showHomeLatestDot; + return Container( + padding: EdgeInsets.symmetric(horizontal: 14.w), + alignment: Alignment.center, + child: Stack( + clipBehavior: Clip.none, + children: [ + Text(e.moduleName ?? ''), + if (showDot) + Positioned( + right: -8, + top: -2, + child: HomeUpdateMarkerProvider.buildRedDot(), + ), + ], + ), + ); + }).toList(), + isScrollable: true, + padding: const EdgeInsets.only(bottom: 2), + labelPadding: EdgeInsets.zero, + unselectedLabelStyle: const TextStyle(fontSize: 14), + unselectedLabelColor: const Color(0x73FFFFFF), + labelStyle: + const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + labelColor: + isDarkStyle ? const Color(0xff810906) : const Color(0xE5FFFFFF), + indicator: CustomIndicator( + height: 2, + width: 16.w, + color: const Color(0xfff68804), + offsetY: 4, + ), + controller: tabCtr, + ); + }, + ); + } + + /// 按 module type/showType/isACG 分发到对应子页,统一 keepAlive + Widget _tabPage(int index) { + final module = logic.tabs[index]; + if (module.type == 1 || module.type == 3) { + //showType==2 走分区聚合页,其余走排序列表页 + return module.showType == 2 + ? HomeTabSectionPage(index, module, isDarkStyle: isDarkStyle) + .keepAlive + : HomeTabSortPage(index, module, isDarkStyle: isDarkStyle).keepAlive; + } + if (module.isACG) { + return CartoonSubDetailPage(tagData: module, type: MediaStyle.Cartoon) + .keepAlive; + } + return HomeTabSortPage(index, module, isDarkStyle: isDarkStyle).keepAlive; + } +} diff --git a/lib/hj_page/home/home_sub_module/home_tab_base_logic.dart b/lib/hj_page/home/home_sub_module/home_tab_base_logic.dart new file mode 100644 index 0000000..33c8e41 --- /dev/null +++ b/lib/hj_page/home/home_sub_module/home_tab_base_logic.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../hj_model/cartoon_media_info.dart'; +import '../../../hj_model/home/plate_model.dart'; +import '../../../hj_model/home/module_detail_model.dart'; +import '../../../hj_model/video_model.dart'; +import '../../main_page/provider/bottom_bar_provider.dart'; + +/// 首页各 tab 逻辑基类:分页状态 + 列表数据 + 滚动/刷新控制器 +/// 子类 HomeTabSortLogic(排序 tab)/ HomeTabSectionLogic(专题 tab)复用 +class HomeTabBaseLogic extends GetxController { + // ===== 外部传入 ===== + final ModuleData tabModel; // tab 配置 + final int index; // tab 下标 + final bool isDarkStyle; // 是否暗网风格 + + HomeTabBaseLogic(this.index, this.tabModel, {this.isDarkStyle = false}); + + // ===== 状态 ===== + int currentPage = 1; + bool isLoadingData = true; + + // ===== 数据 ===== + ModuleDetailModel? dataSource; + + // ===== Controller ===== + // scrollCtr 注册进单例 BottomProvider(持引用 + 加监听 + scrollToTop),生命周期比本类长; + // 本类不 dispose,否则单例再操作已释放的控制器会崩(交给单例托管,谁最终使用谁负责) + final ScrollController scrollCtr = ScrollController(); + // refreshCtr 由页面 pullYsRefresh 的 onInit 注入、CustomRefreshView 负责释放,本类只持引用 + RefreshController? refreshCtr; + + // ===== 派生 getter ===== + bool get isShowAd => tabModel.pureVersion != true; // 纯净版不展示广告 + List get allMedia => dataSource?.allMediaInfo ?? []; + List get allVideo => dataSource?.allVideoInfo ?? []; + List get allSection => dataSource?.allSection ?? []; + List get chosenVideoInfo => dataSource?.chosenVideoInfo ?? []; // 精选视频 + + @override + void onInit() { + super.onInit(); + _registerScrollController(); + } + + // 把本 tab 的滚动控制器注册到底部栏 Provider,联动「回到顶部」按钮与吸顶态 + void _registerScrollController() { + if (isDarkStyle) { + DarkwebBottomProvider().setScrollController(index, controller: scrollCtr); + } else { + HomeBottomProvider().setScrollController(index, controller: scrollCtr); + } + } +} diff --git a/lib/hj_page/home/home_sub_module/home_tab_section_logic.dart b/lib/hj_page/home/home_sub_module/home_tab_section_logic.dart new file mode 100644 index 0000000..bf0a29d --- /dev/null +++ b/lib/hj_page/home/home_sub_module/home_tab_section_logic.dart @@ -0,0 +1,136 @@ +import 'package:hgdj/tools_base/ad_manager.dart'; + +import '../../../hj_model/home/module_detail_model.dart'; +import '../../../hj_utils/api_service/vid_service.dart'; +import '../../../tools_base/debug_log.dart'; +import '../../../tools_base/global_store/store.dart'; +import 'home_tab_base_logic.dart'; + +class HomeTabSectionLogic extends HomeTabBaseLogic { + HomeTabSectionLogic(super.index, super.tabModel, {super.isDarkStyle}); + + bool lastIsGuessLike = false; // true 最后一个是猜你喜欢 + //猜你喜欢当前选中的 tab **下标**,与 GuessLikeSliver 的标题一一对应: + //0 最多收藏 / 1 最新上架 / 2 最多观看 + int guessLikeSortType = 0; + + //上面的下标 → 后端 sort 值:最多收藏=0 最新上架=1 最多观看=3。 + //标题在 GuessLikeSliver 里、值在这里,两处顺序必须一致,加减一项就会整体错位 + List gLikeSortParam = [0, 1, 3]; + int guessLikePage = 1; + + @override + void onReady() { + initData(); + super.onReady(); + } + + void sortGuessLikeExchange(int value) { + if (value == guessLikeSortType) { + return; + } + if (lastIsGuessLike) { + allSection.last.allVideoInfo = null; + } + guessLikeSortType = value; + update(); + _loadGuessLike(page: 1, sortType: guessLikeSortType); + } + + void initData() async { + isLoadingData = true; + update(); + _loadData(); + } + + void refreshData() { + _loadData(); + } + + void loadMoreData() { + if (lastIsGuessLike) { + _loadGuessLike(page: guessLikePage + 1, sortType: guessLikeSortType); + } else { + _loadData(page: currentPage + 1); + } + } + + void _loadData({int page = 1, int size = 5}) async { + try { + ModuleDetailModel? retResp = await VidService.getModuleDetail( + tabModel.id ?? '', + pageNumber: page, + pageSize: size); + + currentPage = page; + retResp?.allSection + ?.removeWhere((element) => element.allVideoInfo?.isEmpty ?? true); + if (currentPage == 1) { + dataSource = retResp; + } else { + dataSource?.allSection?.addAll(retResp?.allSection ?? []); + } + //ab测试 + for (AllSection section in (dataSource?.allSection ?? [])) { + globalStore.filterShowType(section.allVideoInfo ?? []); + } + if (isShowAd) { + AdManager().insertSectionAds( + dataSource?.allSection ?? [], + AdManager().adsByType(5), + adGap: 2, + ); + } + if (retResp?.hasNext == true) { + lastIsGuessLike = false; + refreshCtr?.loadComplete(); + } else { + if (retResp?.allSection?.last.isGuessLike == true) { + lastIsGuessLike = true; + guessLikeSortType = 0; + guessLikePage = 1; + if (dataSource?.allSection?.last.isAdsArr() == true) { + dataSource?.allSection?.removeLast(); + } + retResp?.allSection?.last.allVideoInfo = null; + update(); + await _loadGuessLike(); + } else { + lastIsGuessLike = false; + refreshCtr?.loadNoData(); + } + } + } catch (e) { + debugLog(e); + refreshCtr?.loadComplete(); + } + refreshCtr?.refreshCompleted(); + isLoadingData = false; + update(); + } + + Future _loadGuessLike({int page = 1, int size = 10, int sortType = 0}) async { + try { + AllSection lastSection = allSection.last; + int sortValue = gLikeSortParam[sortType]; + AllSection? retModel = await VidService.getGuessLike( + page, size, tabModel.id ?? "", sortValue); + if (sortType != guessLikeSortType) { + return; + } + guessLikePage = page; + lastSection.allVideoInfo ??= []; + if (lastSection.allVideoInfo?.isNotEmpty == true && page == 1) { + lastSection.allVideoInfo?.clear(); + } + lastSection.allVideoInfo?.addAll(retModel?.allVideoInfo ?? []); + retModel?.hasNext == false + ? refreshCtr?.loadNoData() + : refreshCtr?.loadComplete(); + } catch (e) { + refreshCtr?.loadComplete(); + } + refreshCtr?.refreshCompleted(); + update(); + } +} diff --git a/lib/hj_page/home/home_sub_module/home_tab_section_page.dart b/lib/hj_page/home/home_sub_module/home_tab_section_page.dart new file mode 100644 index 0000000..682884c --- /dev/null +++ b/lib/hj_page/home/home_sub_module/home_tab_section_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; + +import '../../../hj_model/home/plate_model.dart'; +import '../../../hj_model/home/module_detail_model.dart'; +import '../../../tools_base/banner/ads_grid_view_widget.dart'; +import '../../../tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import '../home_cell_style/guess_like_sliver.dart'; +import '../home_cell_style/quick_entry_row.dart'; +import '../home_cell_style/home_section_cell.dart'; +import 'home_tab_section_logic.dart'; + +/// 海角样式 +class HomeTabSectionPage extends StatefulWidget { + final ModuleData tabModel; + final bool isDarkStyle; + final int tabIndex; + + const HomeTabSectionPage( + this.tabIndex, + this.tabModel, { + super.key, + this.isDarkStyle = false, + }); + + @override + State createState() => _HomeTabSectionPageState(); +} + +class _HomeTabSectionPageState extends State + with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: HomeTabSectionLogic(widget.tabIndex, widget.tabModel, + isDarkStyle: widget.isDarkStyle), + tag: uniqueTag, + builder: (logic) { + return Container( + child: pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onLoading: (ctr) => logic.loadMoreData(), + onRefresh: (ctr) => logic.refreshData(), + child: CustomScrollView( + controller: logic.scrollCtr, + slivers: [ + if (logic.isShowAd) + SliverToBoxAdapter( + child: AdsGridViewWidget( + 4, + accordingAdsType: true, + padding: EdgeInsets.fromLTRB(12, 0, 12, 12), + ), + ), + SliverToBoxAdapter(child: QuickEntryRow(widget.tabModel)), + if (logic.isLoadingData) + SliverToBoxAdapter( + child: Container( + height: 300, + child: LoadingCenterWidget(), + ), + ) + else if (logic.allSection.isEmpty) + SliverToBoxAdapter( + child: Container( + height: 300, + child: CErrorWidget( + retryOnTap: () => logic.initData(), + ), + ), + ) + else + ..._buildContent(logic), + ], + ), + ), + ); + }, + ); + } + + List _buildContent(HomeTabSectionLogic logic) { + return [ + SliverToBoxAdapter(child: SizedBox(height: 12)), + SliverList.builder( + itemBuilder: (BuildContext context, int index) { + AllSection section = logic.allSection[index]; + if (section.isAdsArr()) { + return AdsGridViewWidget( + -1, + adsArr: section.adsInfoArr ?? [], + padding: EdgeInsets.fromLTRB(12, 0, 12, 16), + ); + } else if (logic.lastIsGuessLike && + index == logic.allSection.length - 1) { + return SizedBox(); // 最后一个猜你喜欢列表单独处理 + } else if (section.allVideoInfo?.isNotEmpty == true) { + //换一批、间距、分割线都在 HomeSectionCell 里 + return HomeSectionCell(section); + } else { + return SizedBox(); + } + }, + itemCount: logic.allSection.length, + ), + if (logic.lastIsGuessLike) + GuessLikeSliver( + logic.allSection.last, + sortIndex: logic.guessLikeSortType, + logic: logic, + ), + ]; + } +} diff --git a/lib/hj_page/home/home_sub_module/home_tab_sort_logic.dart b/lib/hj_page/home/home_sub_module/home_tab_sort_logic.dart new file mode 100644 index 0000000..3957405 --- /dev/null +++ b/lib/hj_page/home/home_sub_module/home_tab_sort_logic.dart @@ -0,0 +1,186 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/home/video_list_model.dart'; +import 'package:hgdj/hj_page/home/provider/home_update_marker_provider.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../hj_model/home/module_detail_model.dart'; +import '../../../hj_utils/api_service/vid_service.dart'; +import '../../../tools_base/ad_manager.dart'; +import '../../../tools_base/debug_log.dart'; +import '../../../tools_base/global_store/store.dart'; +import 'home_tab_base_logic.dart'; + +const Interval = 4; + +class HomeTabSortLogic extends HomeTabBaseLogic { + HomeTabSortLogic(super.index, super.tabModel, {super.isDarkStyle}); + + //普通排序:后端没配就用这套默认 + List> get _sortTabs => + tabModel.sortTabs ?? + const [ + SortTab('热门推荐', 2), + SortTab('最新上架', 1), + SortTab('最新热评', 9), + SortTab('最多收藏', 7), + ]; + List get sortTitles => _sortTabs.map((e) => e.name).toList(); + // 初值跟第一个 tab 走:HomeSortHeader 的 TabController 默认选中 index 0,而后端可能用 top 把别的排序项置顶, + // 写死 2 就会出现「高亮第一个 tab、请求发的却是本月最热」的错位(连带影响精选视频展示、随机刷新判断) + late int moduleSort = _sortTabs.first.sort; // 1: 最新发布 2:本月最热 9:最新热评 7:最多收藏 + + //"最新"风格固定排序 + static const _lastestSortTabs = [ + SortTab('今日最新', 1), + SortTab('本周最热', 2), + SortTab('本月最热', 4), + SortTab('年度最热', 5), + ]; + List get lastestSortTitles => + _lastestSortTabs.map((e) => e.name).toList(); + int lastestModuleSort = 1; + + // 一排几个:haiJiaoStyle.defaultShow 0-一排两个(网格) 1-一排一个(单列),缺省按网格 + late final RxBool isGridStyle = (tabModel.haiJiaoStyle?.defaultShow != 1).obs; + RxBool isSortMenuInTop = false.obs; + + bool get isNewestStyle => tabModel.id == '-110'; + + // 请求序号:每发一次 +1,响应回来时序号已变说明期间切了排序 / 又发了新请求,旧响应直接丢弃。 + // 否则先发后回的结果会盖掉新排序的列表,或把分页偏移打乱(如上一页的 addAll 追加到刷新后的列表尾部) + int _reqSeq = 0; + + // 当前排序项是否走随机刷新接口:只认后端下发的 refreshMode,不匹配标题、不硬编码 sort 值 + // 暗网亚模块产品上就不做随机,别删这个判断——后端对没配 refreshMode 的历史数据会把 + // val=2 兜底成 RANDOM_TOP_N,只靠后端配置挡不住 + bool get _isRandomRefresh { + if (isNewestStyle || isDarkStyle) return false; + return tabModel.sortRuleOf(moduleSort)?.isRandomRefresh == true; + } + + void initData() async { + isLoadingData = true; + update(); + _loadData(sortValue: isNewestStyle ? lastestModuleSort : moduleSort); + } + + @override + void onReady() { + initData(); + super.onReady(); + } + + void sortMenuEvent(int value) { + if (isNewestStyle) { + lastestModuleSort = _lastestSortTabs[value].sort; + if (lastestModuleSort == 1) { + HomeUpdateMarkerProvider().markTodayLatestViewed(); + } + } else { + moduleSort = _sortTabs[value].sort; + } + isLoadingData = true; + update(); + _loadData(sortValue: isNewestStyle ? lastestModuleSort : moduleSort); + } + + void onTooleAction() { + isGridStyle.value = !isGridStyle.value; + update(); + } + + void refreshData() { + // 随机刷新排序项每次主动下拉都换新 token,后端据此重排候选池; + // 网络层自动重试沿用同一 URL(token 不变),拿到的顺序与首次一致,不会跳序 + _loadData( + sortValue: isNewestStyle ? lastestModuleSort : moduleSort, + refreshToken: _isRandomRefresh ? const Uuid().v4() : null, + ); + } + + void loadMoreData() { + _loadData( + page: currentPage + 1, + sortValue: isNewestStyle ? lastestModuleSort : moduleSort); + } + + // 每页固定 30:随机刷新接口也传同一个 size,分页偏移才对齐不重叠 + // [refreshToken] 非空 = 走热门随机刷新接口,只换视频列表 + void _loadData( + {int page = 1, + int size = 30, + int sortValue = 0, + String? refreshToken}) async { + final seq = ++_reqSeq; + try { + bool hasNext = false; + if (isNewestStyle) { + VideoListResp? retResp = await VidService.getNewestModule( + sortType: sortValue, + pageNumber: page, + pageSize: size, + ); + if (seq != _reqSeq) return; // 已被更新的请求取代,本次结果作废 + currentPage = page; + if (currentPage == 1) { + dataSource = ModuleDetailModel(allVideoInfo: retResp?.videos ?? []); + } else { + dataSource?.allVideoInfo?.addAll(retResp?.videos ?? []); + } + hasNext = retResp?.hasNext ?? false; + } else if (refreshToken != null && dataSource != null) { + // 热门随机刷新:接口只回 allVideoInfo,专题/精选/漫画等结构沿用上次结果,不能整个换掉 dataSource + // 首屏还没成功过(dataSource 为空)时不走这里,让它走下面的常规接口拿全量结构 + final retResp = await VidService.refreshRandomModule( + tabModel.id ?? '', + moduleSort: sortValue, + refreshToken: refreshToken, + pageSize: size, + ); + if (seq != _reqSeq) return; // 已被更新的请求取代,本次结果作废 + // 刷新接口的 hasNext 恒为 true(随机只换第一页,到底没到底由后续常规分页说了算),取不到按 true 兜底 + hasNext = retResp?.hasNext ?? true; + final list = retResp?.allVideoInfo; + // 请求失败或响应没带 allVideoInfo 都算这次刷新没成:列表和页码原样留着, + // 别把用户正看着的内容清空、也别把分页偏移打乱(后端真没内容会回空数组,那才该清) + if (list != null) { + dataSource?.allVideoInfo = list; + currentPage = 1; // 刷新成功才重置页码,下次上拉从 pageNumber=2 开始 + } + } else { + ModuleDetailModel? retResp = await VidService.getModuleDetail( + tabModel.id ?? '', + moduleSort: sortValue, + pageNumber: page, + pageSize: size); + if (seq != _reqSeq) return; // 已被更新的请求取代,本次结果作废 + + currentPage = page; + if (currentPage == 1) { + dataSource = retResp; + } else { + dataSource?.allVideoInfo?.addAll(retResp?.allVideoInfo ?? []); + dataSource?.allMediaInfo?.addAll(retResp?.allMediaInfo ?? []); + } + hasNext = retResp?.hasNext ?? false; + } + //ab测试 + globalStore.filterShowType(dataSource?.allVideoInfo ?? []); + dataSource?.allVideoInfo?.removeWhere((element) => element.isRandomAd()); + if (isShowAd) { + AdManager().insertGroupAds( + dataSource?.allVideoInfo ?? [], AdManager().adsByType(11), + adGap: 6); + } + hasNext ? refreshCtr?.loadComplete() : refreshCtr?.loadNoData(); + } catch (e) { + debugLog(e); + if (seq != _reqSeq) return; // 旧请求失败别去动刷新态,新请求还在跑 + refreshCtr?.loadComplete(); + } + refreshCtr?.refreshCompleted(); + isLoadingData = false; + update(); + } +} diff --git a/lib/hj_page/home/home_sub_module/home_tab_sort_page.dart b/lib/hj_page/home/home_sub_module/home_tab_sort_page.dart new file mode 100644 index 0000000..3237b18 --- /dev/null +++ b/lib/hj_page/home/home_sub_module/home_tab_sort_page.dart @@ -0,0 +1,316 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +import '../../../hj_model/cartoon_media_info.dart'; +import '../../../hj_model/home/plate_model.dart'; +import '../../../hj_model/video_model.dart'; +import '../../../hj_utils/sliver_delegate.dart'; +import '../../../tools_base/banner/ads_grid_view_widget.dart'; +import '../../../tools_base/refresh/pull_refresh.dart'; +import '../../cartoon/acg_widget_item.dart'; +import '../home_cell_style/quick_entry_row.dart'; +import '../home_cell_style/video_simple_cell.dart'; +import 'home_tab_sort_logic.dart'; +import 'widget/sort_header.dart'; +import 'widget/special_topics_view.dart'; + +/// 海角样式 +class HomeTabSortPage extends StatefulWidget { + final ModuleData tabModel; + final bool isDarkStyle; + final int tabIndex; + + const HomeTabSortPage( + this.tabIndex, + this.tabModel, { + super.key, + this.isDarkStyle = false, + }); + + @override + State createState() => _HomeTabSortPageState(); +} + +class _HomeTabSortPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: HomeTabSortLogic(widget.tabIndex, widget.tabModel, + isDarkStyle: widget.isDarkStyle), + tag: uniqueTag, + builder: (logic) { + return Stack( + children: [ + Container( + child: pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onLoading: (ctr) => logic.loadMoreData(), + onRefresh: (ctr) => logic.refreshData(), + child: CustomScrollView( + controller: logic.scrollCtr, + slivers: [ + SliverToBoxAdapter( + child: 6.sizeBoxH, + ), + if (logic.isShowAd && !logic.isNewestStyle) + SliverToBoxAdapter( + child: AdsGridViewWidget( + 4, + accordingAdsType: true, + padding: EdgeInsets.fromLTRB(12, 0, 12, 12), + ), + ), + SliverToBoxAdapter(child: QuickEntryRow(widget.tabModel)), + SliverToBoxAdapter( + child: SpecialTopicsView( + logic.allSection, + module: widget.tabModel, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + isDarkStyle: widget.isDarkStyle, + ), + ), + if (widget.tabModel.haiJiaoStyle?.sortShow == 1 || + logic.isNewestStyle) + SliverPersistentHeader( + pinned: true, + delegate: MySliverDelegate( + maxHeight: 42, + minHeight: 42, + callTop: (shrinkOffset) { + if (shrinkOffset > 0) { + WidgetsBinding.instance + .addPostFrameCallback((timeStamp) { + logic.isSortMenuInTop.value = false; + }); + } + if (shrinkOffset <= 0) { + WidgetsBinding.instance + .addPostFrameCallback((timeStamp) { + logic.isSortMenuInTop.value = true; + }); + } + }, + child: Obx( + () { + Color? bgColor; + if (widget.isDarkStyle) { + if (logic.isSortMenuInTop.value) { + bgColor = Colors.transparent; + } else { + bgColor = Colors.black; + } + } + return HomeSortHeader( + isLatest: logic.isNewestStyle, + labelPadding: + const EdgeInsets.symmetric(horizontal: 10), + tabs: logic.isNewestStyle + ? logic.lastestSortTitles + : logic.sortTitles, + bgColor: bgColor, + onSort: logic.sortMenuEvent, + rightWidget: logic.isNewestStyle + ? null + : _buildSortStyleMenu(logic), + ); + }, + ), + ), + ), + _buildContent(logic), + ], + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildSortStyleMenu(HomeTabSortLogic logic) { + if (widget.tabModel.isACG) return SizedBox(); + return Container( + padding: EdgeInsets.only(right: 12), + child: InkWell( + enableFeedback: false, + onTap: logic.onTooleAction, + child: Row( + children: [ + Text( + '切换', + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: .6), + ), + ), + 2.sizeBoxW, + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, anim) => + ScaleTransition(scale: anim, child: child), + child: Image.asset( + logic.isGridStyle.value + ? 'list_style.webp'.homePath + : 'grid_style.webp'.homePath, + key: ValueKey(logic.isGridStyle.value), + width: 18, + ), + ), + ], + ), + ), + ); + } + + Widget _buildContent(HomeTabSortLogic logic) { + if (logic.isLoadingData) { + return SliverFillRemaining( + child: LoadingCenterWidget(), + ); + } else if (logic.allVideo.isEmpty && logic.allMedia.isEmpty) { + return SliverFillRemaining( + child: CErrorWidget( + retryOnTap: () => logic.initData(), + ), + ); + } else { + if (widget.tabModel.isACG) { + return SliverPadding( + padding: EdgeInsets.fromLTRB(16, 10, 16, 0), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 12, + crossAxisSpacing: 5, + childAspectRatio: 111 / 194, //子控件宽高比 + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + CartoonMediaInfo info = logic.allMedia[index]; + return AcgItemWidget(info: info); + }, + childCount: logic.allMedia.length, + ), + ), + ); + } else { + if (widget.tabModel.haiJiaoStyle?.showChosenVideo == 1 && + logic.chosenVideoInfo.isNotEmpty && + logic.moduleSort == 2) { + //热门推荐,每个6个插入一个精品视频数据 + return SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + sliver: SliverMainAxisGroup( + slivers: _buildChosenVideoStyle(logic), + ), + ); + } else { + return SliverPadding( + padding: EdgeInsets.fromLTRB(16, 10, 16, 0), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: logic.isGridStyle.value ? 2 : 1, + mainAxisSpacing: logic.isGridStyle.value ? 12 : 14, + crossAxisSpacing: 7, + childAspectRatio: + logic.isGridStyle.value ? 168 / 164 : 340 / 232, //子控件宽高比 + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + VideoModel videoModel = logic.allVideo[index]; + return GestureDetector( + onTap: () async { + pushToVideoPage(videoModel: videoModel); + }, + child: VideoSimpleCell( + videoModel: videoModel, + textLines: logic.isGridStyle.value ? 2 : 1, + ), + ); + }, + childCount: logic.allVideo.length, + ), + ), + ); + } + } + } + } + + List _buildChosenVideoStyle(HomeTabSortLogic logic) { + List slivers = []; + int gapValue = 6; + int allIndex = logic.allVideo.length ~/ gapValue; + int leftCount = logic.allVideo.length % gapValue; + int insertIndex = 0; + for (int i = 0; i < allIndex; i++) { + List subList = + logic.allVideo.sublist(i * gapValue, i * gapValue + gapValue); + slivers.add(SliverPadding( + padding: EdgeInsets.only(bottom: 12), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: logic.isGridStyle.value ? 2 : 1, + mainAxisSpacing: logic.isGridStyle.value ? 10 : 12, + crossAxisSpacing: 7, + childAspectRatio: + logic.isGridStyle.value ? 168 / 144 : 343 / 260, //子控件宽高比 + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + VideoModel videoModel = subList[index]; + return VideoSimpleCell( + videoModel: videoModel, + ); + }, + childCount: subList.length, + ), + ), + )); + if (insertIndex < logic.chosenVideoInfo.length) { + VideoModel insertVM = logic.chosenVideoInfo[insertIndex]; + slivers.add(SliverToBoxAdapter( + child: Container( + padding: EdgeInsets.only(bottom: 12), + child: AspectRatio( + aspectRatio: 343 / 260, + child: VideoSimpleCell(videoModel: insertVM), + ), + ), + )); + insertIndex++; + } + } + if (leftCount > 0) { + List subList = logic.allVideo.sublist(allIndex * gapValue); + slivers.add(SliverPadding( + padding: EdgeInsets.only(bottom: 12), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: logic.isGridStyle.value ? 2 : 1, + mainAxisSpacing: logic.isGridStyle.value ? 10 : 12, + crossAxisSpacing: 7, + childAspectRatio: + logic.isGridStyle.value ? 168 / 144 : 343 / 260, //子控件宽高比 + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + VideoModel videoModel = subList[index]; + return VideoSimpleCell( + videoModel: videoModel, + ); + }, + childCount: subList.length, + ), + ), + )); + } + return slivers; + } +} diff --git a/lib/hj_page/home/home_sub_module/widget/sort_header.dart b/lib/hj_page/home/home_sub_module/widget/sort_header.dart new file mode 100644 index 0000000..ca4543d --- /dev/null +++ b/lib/hj_page/home/home_sub_module/widget/sort_header.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_page/home/provider/home_update_marker_provider.dart'; +import 'package:provider/provider.dart'; + +/// 首页模块的排序切换栏(热门推荐 / 最新上架 / ...) +/// [isLatest]:「最新」页专用紧凑样式——字号更小、选中态是红色胶囊块、首个 tab 带更新红点 +class HomeSortHeader extends StatefulWidget { + final ValueChanged onSort; + final List? tabs; + final EdgeInsets? labelPadding; + final Widget? rightWidget; + final Color? bgColor; + final bool isLatest; + + const HomeSortHeader({ + super.key, + required this.onSort, + this.tabs, + this.labelPadding, + this.rightWidget, + this.bgColor, + this.isLatest = false, + }); + + @override + State createState() => _HomeSortHeaderState(); +} + +class _HomeSortHeaderState extends State + with SingleTickerProviderStateMixin { + late final List tabs = + widget.tabs ?? const ['热门推荐', '最新上架', '最新热评', '最多收藏']; + late final TabController tabCtr = + TabController(length: tabs.length, vsync: this); + + @override + void dispose() { + tabCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: 38, + color: widget.bgColor ?? Theme.of(context).scaffoldBackgroundColor, + child: widget.rightWidget == null + ? _tabBar() + : Row(children: [Expanded(child: _tabBar()), widget.rightWidget!]), + ); + } + + Widget _tabBar() { + final fontSize = widget.isLatest ? 12.0 : 14.0; + return TabBar( + controller: tabCtr, + isScrollable: true, + tabAlignment: TabAlignment.center, + onTap: widget.onSort, + indicatorWeight: 0, + //最新页选中态是红色胶囊块,普通页无指示器(靠字号/字重区分) + indicator: widget.isLatest + ? BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3)) + : const BoxDecoration(), + labelPadding: widget.isLatest + ? const EdgeInsets.symmetric(horizontal: 4) + : widget.labelPadding ?? const EdgeInsets.symmetric(horizontal: 8), + labelStyle: TextStyle( + color: const Color(0xE5FFFFFF), + fontSize: fontSize, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: + TextStyle(color: const Color(0x73FFFFFF), fontSize: fontSize), + tabs: List.generate(tabs.length, (i) => _tab(i, tabs[i])), + ); + } + + /// 单个 tab。红点只可能落在「最新」页的首个 tab 上, + /// 所以只让它订阅 provider——其余 tab 不会因红点变化而重建 + Widget _tab(int index, String label) { + if (!widget.isLatest) return Text(label); + final text = Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Text(label), + ); + if (index != 0) return text; + return Consumer( + builder: (_, marker, __) => Stack( + clipBehavior: Clip.none, + children: [ + text, // Stack 尺寸取它,有无红点 tab 宽高一致 + if (marker.showTodayLatestDot) + Positioned( + right: 4, + top: 0, + child: HomeUpdateMarkerProvider.buildRedDot()), + ], + ), + ); + } +} diff --git a/lib/hj_page/home/home_sub_module/widget/special_topics_view.dart b/lib/hj_page/home/home_sub_module/widget/special_topics_view.dart new file mode 100644 index 0000000..268c7b3 --- /dev/null +++ b/lib/hj_page/home/home_sub_module/widget/special_topics_view.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/home/plate_model.dart'; +import 'package:hgdj/hj_model/home/module_detail_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/refresh/horizontal_load_more.dart'; + +import '../../../../tools_base/widget/net_image_widget.dart'; +import '../../../cartoon/cartoon_sectionlist_page.dart'; +import '../../section_all_page/section_all_page.dart'; +import '../../special_topic_detail/special_topics_detail_page.dart'; + +/// 首页/漫画页的专题位,样式由 [module].haiJiaoStyle.sectionStyle 决定: +/// 0-不展示 1-圆头像横滑(17岁) 2/3-原创达人(女优/网黄) 4-文字标签网格 5-带标题的圆头像横滑(图列) +class SpecialTopicsView extends StatelessWidget { + final List? specials; + final ModuleData? module; + final EdgeInsetsGeometry? padding; + final bool isDarkStyle; + + const SpecialTopicsView( + this.specials, { + super.key, + this.module, + this.padding, + this.isDarkStyle = false, + }); + + int get _style => module?.haiJiaoStyle?.sectionStyle ?? 0; + + @override + Widget build(BuildContext context) { + final list = specials; + if (list == null || list.isEmpty) return const SizedBox.shrink(); + switch (_style) { + case 1: + return _avatarRow(list, height: 88, imgSize: 60); + case 2: + case 3: + return _actressSection(list); + case 4: + return _tagGrid(list); + case 5: + return _imageSection(list); + default: + return const SizedBox.shrink(); + } + } + + /// 进「原创达人」全部列表页。标题右侧的「更多」和横滑到底松手都走这里 + void _gotoSectionAll() => Get.to(SectionAllPage(module?.id ?? "")); + + /// 专题点击:漫画模块进漫画专区列表,其余进专题详情 + /// 注:原创达人区不走这里,它固定进专题详情(见 [_actressSection]) + void _onTap(AllSection model) { + if (module?.isACG == true) { + Get.to(CartoonSectionListPage( + sectionID: model.sectionID, tagName: model.sectionName)); + } else { + Get.to(SpecialTopicsDetailPage(model), preventDuplicates: false); + } + } + + // ========== 样式 1 / 5:圆头像横滑 ========== + + Widget _avatarRow(List list, + {required double height, required double imgSize}) { + return Container( + margin: padding, + height: height, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: list.length, + itemBuilder: (_, i) => _avatarItem(list[i], imgSize), + ), + ); + } + + Widget _avatarItem(AllSection model, double imgSize) { + return GestureDetector( + onTap: () => _onTap(model), + child: Container( + width: 64, + margin: const EdgeInsets.only(right: 16), + child: Column( + children: [ + NetworkImageLoader( + imageUrl: model.sectionCover ?? '', + width: imgSize, + height: imgSize, + borderRadius: imgSize / 2, + ), + 4.sizeBoxH, + Text( + model.sectionName ?? '', + style: const TextStyle(color: Color(0xE5FFFFFF), fontSize: 12), + maxLines: 1, + ), + ], + ), + ), + ); + } + + /// 样式 5:标题 + 圆头像横滑。标题固定取第一个专题名 + /// 注意标题不套 [padding](贴左边缘),只有下方列表有边距——与设计稿一致,别顺手加上 + Widget _imageSection(List list) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + list.first.sectionName ?? "", + maxLines: 1, + style: const TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 18, + fontWeight: FontWeight.w500), + ), + _avatarRow(list, height: 84, imgSize: 55), + ], + ); + } + + // ========== 样式 4:文字标签网格 ========== + + Widget _tagGrid(List list) { + return GridView.builder( + shrinkWrap: true, + padding: padding, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 10, + crossAxisSpacing: 9, + childAspectRatio: 82 / 33, + ), + itemCount: list.length, + itemBuilder: (_, i) => _tagItem(list[i]), + ); + } + + Widget _tagItem(AllSection model) { + return GestureDetector( + onTap: () => _onTap(model), + child: Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + color: + isDarkStyle ? const Color(0x4D810906) : const Color(0x0DFFFFFF), + borderRadius: const BorderRadius.all(Radius.circular(2)), + ), + child: Stack( + alignment: Alignment.center, + children: [ + if (isDarkStyle) + Positioned( + top: 0, + child: Image.asset("aw_tag.webp".homePath, height: 13)), + Text( + "${model.sectionName}", + style: const TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 12, + fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (model.hot == true) + Positioned( + top: 0, + right: 0, + child: Image.asset("home_hot.webp".homePath, height: 20)), + ], + ), + ), + ); + } + + // ========== 样式 2 / 3:原创达人 ========== + + Widget _actressSection(List list) { + return Container( + margin: padding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + enableFeedback: false, + onTap: _gotoSectionAll, + child: Row( + children: [ + const Expanded( + child: Text( + '原创达人', + maxLines: 1, + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 18, + fontWeight: FontWeight.w500), + ), + ), + 10.sizeBoxW, + const Text('更多', + maxLines: 1, + style: TextStyle(color: Color(0x59FFFFFF), fontSize: 12)), + Image.asset("arrow_right_grey.webp".commonImgPath, width: 18), + ], + ), + ), + 12.sizeBoxH, + // 一屏排 5.5 个(露出半个提示可滑)+ 5 个 12 的间隔; + // 高度 = 圆头像(正方形,边长即 item 宽) + 2 间距 + 20 文字 + LayoutBuilder( + builder: (_, c) { + final itemW = (c.maxWidth - 12 * 5) / 5.5; + return SizedBox( + height: itemW + 22, + // 横向「拉到底查看更多」:滑到末尾继续拉、松手跳原创达人列表页 + child: HorizontalLoadMore( + onTrigger: _gotoSectionAll, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.zero, + itemCount: list.length, + separatorBuilder: (_, __) => 12.sizeBoxW, + itemBuilder: (_, i) => _actressItem(list[i], itemW), + ), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _actressItem(AllSection model, double itemW) { + return GestureDetector( + //达人固定进专题详情,不走 _onTap 的漫画分支 + onTap: () => + Get.to(SpecialTopicsDetailPage(model), preventDuplicates: false), + child: SizedBox( + height: double.infinity, + width: itemW, + child: Column( + children: [ + NetworkImageLoader( + imageUrl: model.sectionCover ?? '', + width: itemW, + height: itemW, + borderRadius: itemW, + ), + 2.sizeBoxH, + Text( + model.sectionName ?? '', + style: const TextStyle(color: Color(0xE5FFFFFF), fontSize: 12), + maxLines: 1, + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/home/provider/home_update_marker_provider.dart b/lib/hj_page/home/provider/home_update_marker_provider.dart new file mode 100644 index 0000000..e39e6c7 --- /dev/null +++ b/lib/hj_page/home/provider/home_update_marker_provider.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_model/home/update_marker_model.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/light_model.dart'; +import 'package:hgdj/hj_utils/store_keys.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +/// 首页「最新 / 今日最新」等内容更新红点 +class HomeUpdateMarkerProvider with ChangeNotifier { + static final HomeUpdateMarkerProvider _instance = + HomeUpdateMarkerProvider._(); + HomeUpdateMarkerProvider._(); + factory HomeUpdateMarkerProvider() => _instance; + + HomeUpdateMarkersResp? _markers; + String? _lastViewHomeLatestAt; + String? _lastViewTodayLatestAt; + final Map _lastViewModuleAt = {}; + + bool get showHomeLatestDot => + _isNewer(_markers?.homeLatestAt, _lastViewHomeLatestAt); + + bool get showTodayLatestDot => + _isNewer(_markers?.todayLatestAt, _lastViewTodayLatestAt); + + bool showModuleDot(String? moduleId) { + if (moduleId == null || moduleId.isEmpty) return false; + return _isNewer(_latestAtOf(moduleId), _lastViewModuleAt[moduleId]); + } + + /// 热门短剧专题 Tab:仅对 `dramaPage` 里的模块判断未读 + bool showDramaTopicDot(String? topicId) { + if (!_isDramaPageModule(topicId)) return false; + return showModuleDot(topicId); + } + + String? _latestAtOf(String moduleId) { + for (final m in _markers?.modules ?? const []) { + if (m.moduleId == moduleId) return m.latestAt; + } + return null; + } + + bool _isDramaPageModule(String? id) { + if (id == null || id.isEmpty) return false; + return Config.plateModule?.dramaPage.any((e) => e.id == id) == true; + } + + /// 拉取标记并与本地最后查看时间对比 + Future loadData() async { + try { + final resp = await CommonService.fetchUpdateMarkers(); + if (resp != null) { + _markers = resp; + } + await _loadLocalLastViewed(); + notifyListeners(); + } catch (e) { + debugLog('HomeUpdateMarkerProvider.loadData error: $e'); + } + } + + Future markHomeLatestViewed() async { + final at = _markers?.homeLatestAt; + if (at == null || at.isEmpty) return; + if (_lastViewHomeLatestAt == at) return; + _lastViewHomeLatestAt = at; + await lightKV.setString(StoreKeys.HOME_LATEST_LAST_VIEW_AT, at); + notifyListeners(); + } + + Future markTodayLatestViewed() async { + final at = _markers?.todayLatestAt; + if (at == null || at.isEmpty) return; + if (_lastViewTodayLatestAt == at) return; + _lastViewTodayLatestAt = at; + await lightKV.setString(StoreKeys.HOME_TODAY_LATEST_LAST_VIEW_AT, at); + notifyListeners(); + } + + Future markModuleViewed(String? moduleId) async { + if (moduleId == null || moduleId.isEmpty) return; + final latest = _latestAtOf(moduleId); + if (latest == null || latest.isEmpty) return; + if (_lastViewModuleAt[moduleId] == latest) return; + _lastViewModuleAt[moduleId] = latest; + await lightKV.setString(_moduleViewKey(moduleId), latest); + notifyListeners(); + } + + /// 8×8 红点,与消息中心一致 + static Widget buildRedDot({bool visible = true}) { + if (!visible) return const SizedBox.shrink(); + return Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xfff74f49), + shape: BoxShape.circle, + ), + ); + } + + Future _loadLocalLastViewed() async { + _lastViewHomeLatestAt = + await lightKV.getString(StoreKeys.HOME_LATEST_LAST_VIEW_AT); + _lastViewTodayLatestAt = + await lightKV.getString(StoreKeys.HOME_TODAY_LATEST_LAST_VIEW_AT); + _lastViewModuleAt.clear(); + for (final m in _markers?.modules ?? const []) { + final id = m.moduleId; + if (id == null || id.isEmpty) continue; + final v = await lightKV.getString(_moduleViewKey(id)); + if (v != null && v.isNotEmpty) { + _lastViewModuleAt[id] = v; + } + } + } + + String _moduleViewKey(String moduleId) { + final uid = globalStore.meInfo?.uid ?? 0; + return '${StoreKeys.HOME_MODULE_LAST_VIEW_AT_PREFIX}${uid}_$moduleId'; + } + + /// 服务端最新时间晚于本地已查看时间则显示红点;本地为空且服务端有值也显示 + static bool _isNewer(String? serverAt, String? localAt) { + if (serverAt == null || serverAt.isEmpty) return false; + if (localAt == null || localAt.isEmpty) return true; + final server = DateTime.tryParse(serverAt); + final local = DateTime.tryParse(localAt); + if (server == null || local == null) { + return serverAt != localAt; + } + return server.isAfter(local); + } +} diff --git a/lib/hj_page/home/search_page/search_main_logic.dart b/lib/hj_page/home/search_page/search_main_logic.dart new file mode 100644 index 0000000..129643f --- /dev/null +++ b/lib/hj_page/home/search_page/search_main_logic.dart @@ -0,0 +1,58 @@ +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/history_util.dart'; + +import 'search_result_page/search_result_main_page.dart'; + +/// 搜索主页逻辑:只管搜索框回填和搜索历史,热门排行由 RankSubPage 自己取数 +class SearchMainLogic extends GetxController { + SearchMainLogic({this.initialResultType}); + + /// 搜索框控制器:由 logic 持有,点热搜词/历史项后能直接回填输入框 + final searchTextCtr = TextEditingController(); + + /// 搜索历史(展示用):去重、排序、上限都由 SearchHistoryStore 负责,这里只缓存结果 + final histories = []; + + /// 结果页默认 Tab(短剧频道进入时为 [MediaStyle.Drama]) + final MediaStyle? initialResultType; + + @override + void onReady() { + super.onReady(); + loadHistories(); + } + + @override + void onClose() { + searchTextCtr.dispose(); + super.onClose(); + } + + /// 重读搜索历史 + Future loadHistories() async { + final list = await HistoryUtil.searchHistories(); + histories + ..clear() + ..addAll(list); + update(['history']); + } + + /// 清空搜索历史 + Future clearHistories() async { + await HistoryUtil.clearSearch(); + histories.clear(); + update(['history']); + } + + /// 提交搜索:记一条历史 → 跳结果页 → 返回后重读(用户此时才重新看到历史列表) + /// 空串由 SearchHistoryStore.save 内部忽略,不入库 + Future onSubmitted(String text) async { + searchTextCtr.text = text; + HistoryUtil.addSearch(text); //不 await:落盘不该挡住跳转,返回时早已写完 + await Get.to( + () => SearchResultMainPage(text, initialType: initialResultType)); + loadHistories(); + } +} diff --git a/lib/hj_page/home/search_page/search_main_page.dart b/lib/hj_page/home/search_page/search_main_page.dart new file mode 100644 index 0000000..5791945 --- /dev/null +++ b/lib/hj_page/home/search_page/search_main_page.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart'; + +import '../../find/rank_module/rank_main_page.dart'; +import '../../find/rank_module/rank_sub_page.dart'; +import 'search_main_logic.dart'; +import 'widget/search_app_bar.dart'; +import 'widget/search_history_view.dart'; +import 'widget/search_hot_tag_view.dart'; + +/// 搜索主页:头部是广告 / 搜索历史 / 热搜词,body 挂视频周榜,两者联动滚动 +class SearchMainPage extends StatelessWidget { + /// 进入页面时预填的搜索词(可为空) + final String? searchText; + + /// 结果页默认 Tab;短剧频道进入时传 [MediaStyle.Drama] + final MediaStyle? initialResultType; + + const SearchMainPage({super.key, this.searchText, this.initialResultType}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: SearchMainLogic(initialResultType: initialResultType), + builder: (logic) { + return Scaffold( + resizeToAvoidBottomInset: false, + body: Column( + children: [ + SearchAppBar( + isClose: true, + searchText: searchText, + controller: logic.searchTextCtr, //由 logic 持有,点热搜词/历史项后回填输入框 + onSubmitted: logic.onSubmitted, + ), + Expanded( + child: NestedScrollView( + headerSliverBuilder: (_, __) => _headerSlivers(logic), + body: const RankSubPage( + pageType: MediaStyle.Video, sortType: 2), // 2 = 周榜 + ), + ), + ], + ), + ); + }, + ); + } + + List _headerSlivers(SearchMainLogic logic) { + return [ + //顶部广告网格(position=32) + SliverToBoxAdapter( + child: AdsGridViewWidget( + 32, + accordingAdsType: true, + padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 12.w), + ), + ), + //搜索历史:随增删局部刷新,不带动整页(id 要和 logic 里 update 的一致) + SliverToBoxAdapter( + child: GetBuilder( + id: 'history', + builder: (_) => SearchHistoryView( + histories: logic.histories, + onHistoryClick: logic.onSubmitted, + onClearAll: logic.clearHistories, + ), + ), + ), + //热搜词 + SliverToBoxAdapter( + child: SearchHotTagView(onTagClick: logic.onSubmitted)), + //「热门排行」标题行,右侧跳完整排行榜页 + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 20, 12, 10), + child: Row( + children: [ + const Text( + '热门排行', + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 18, + fontWeight: FontWeight.w600), + ), + const Spacer(), + InkWell( + enableFeedback: false, + onTap: () => Get.to(() => const RankMainPage(), + preventDuplicates: false), + child: Container( + width: 54, + height: 18, + alignment: Alignment.centerRight, + margin: const EdgeInsets.only(left: 8), + child: Row( + children: [ + Text('更多', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.55), + fontSize: 14)), + Icon(Icons.navigate_next, + size: 22, + color: Colors.white.withValues(alpha: 0.55)), + ], + ), + ), + ), + ], + ), + ), + ), + ]; + } +} diff --git a/lib/hj_page/home/search_page/search_result_page/search_acg_result_page.dart b/lib/hj_page/home/search_page/search_result_page/search_acg_result_page.dart new file mode 100644 index 0000000..3ed9c7a --- /dev/null +++ b/lib/hj_page/home/search_page/search_result_page/search_acg_result_page.dart @@ -0,0 +1,99 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/api_service/acg_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../../hj_model/acg/cartoon_more_list.dart'; +import '../../../../hj_model/cartoon_media_info.dart'; +import '../../../../tools_base/refresh/pull_refresh.dart'; +import '../../../cartoon/acg_widget_item.dart'; + +class SearchACGResultPage extends StatefulWidget { + final String keywords; + final MediaStyle type; + + SearchACGResultPage({ + super.key, + required this.type, + required this.keywords, + }); + + @override + State createState() => _SearchACGResultPageState(); +} + +class _SearchACGResultPageState extends State { + RefreshController? refreshController; //由 pullYsRefresh 的 onInit 注入,组件负责释放 + int pageNumber = 1; + List? videoList; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _loadData(); + }); + } + + _loadData({int page = 1, int size = 12}) async { + try { + MediaSearchListModel? retResp = await ACGService.mediaSearch( + keyword: widget.keywords, + page: page, + size: size, + kind: widget.type.searchKind); + pageNumber = page; + videoList ??= []; + if (page == 1) { + videoList?.clear(); + } + videoList?.addAll(retResp?.list ?? []); + retResp?.hasNext == false + ? refreshController?.loadNoData() + : refreshController?.loadComplete(); + } catch (e) { + refreshController?.loadComplete(); + debugLog(e); + } + refreshController?.refreshCompleted(); + videoList ??= []; + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + if (videoList == null) { + return LoadingCenterWidget(); + } else if (videoList?.isEmpty == true) { + return CErrorWidget( + retryOnTap: () { + videoList = null; + setState(() {}); + _loadData(); + }, + ); + } else { + return pullYsRefresh( + onInit: (ctr) => refreshController = ctr, + onLoading: (ctr) => _loadData(page: pageNumber + 1), + onRefresh: (ctr) => _loadData(), + child: GridView.builder( + padding: EdgeInsets.fromLTRB(12, 12, 12, 0), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: 111 / 190, + ), + itemCount: videoList?.length ?? 0, + itemBuilder: (context, index) { + return AcgItemWidget(info: videoList![index]); + }, + ), + ); + } + } +} diff --git a/lib/hj_page/home/search_page/search_result_page/search_result_main_page.dart b/lib/hj_page/home/search_page/search_result_page/search_result_main_page.dart new file mode 100644 index 0000000..c2849b9 --- /dev/null +++ b/lib/hj_page/home/search_page/search_result_page/search_result_main_page.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/history_util.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../../../../tools_base/indicator/custom_tab_indicator.dart'; +import '../widget/search_app_bar.dart'; +import 'search_acg_result_page.dart'; +import 'search_video_result_page.dart'; + +class SearchResultMainPage extends StatefulWidget { + final String keywords; + + /// 从短剧频道进入时默认切到短剧 Tab(对应 kind=4) + final MediaStyle? initialType; + + const SearchResultMainPage(this.keywords, {super.key, this.initialType}); + + @override + State createState() { + return _SearchResultMainPageState(); + } +} + +class _SearchResultMainPageState extends State + with SingleTickerProviderStateMixin { + final typeList = ['影片', '短剧', '抖音', '漫画', '动漫', '帖子', '图集']; + + late final TabController tabCtr; + late String keywords = widget.keywords; + + static int _tabIndexOf(MediaStyle? type) => switch (type) { + MediaStyle.Drama => 1, + MediaStyle.ShortVideo => 2, + MediaStyle.Comics => 3, + MediaStyle.Cartoon => 4, + MediaStyle.Community => 5, + MediaStyle.Pic => 6, + _ => 0, + }; + + @override + void initState() { + super.initState(); + tabCtr = TabController( + length: typeList.length, + vsync: this, + initialIndex: _tabIndexOf(widget.initialType), + ); + } + + @override + void dispose() { + tabCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Column( + children: [ + SearchAppBar( + searchText: keywords, + onSubmitted: (value) { + HistoryUtil.addSearch(value); //结果页里再次搜索同样记历史 + keywords = value; + setState(() {}); + }, + ), + Container( + height: 32, + margin: EdgeInsets.symmetric(horizontal: 12), + child: TabBar( + tabAlignment: TabAlignment.fill, + tabs: typeList.map((e) { + return Container( + alignment: Alignment.center, + child: Text(e), + ); + }).toList(), + labelStyle: TextStyle(fontSize: 14), + labelColor: Color(0xE5FFFFFF), + unselectedLabelStyle: TextStyle(fontSize: 14), + unselectedLabelColor: Color(0x73FFFFFF), + controller: tabCtr, + padding: EdgeInsets.zero, + isScrollable: false, + labelPadding: EdgeInsets.zero, + indicator: const CustomIndicator( + height: 4, width: 16, isGradient: true, offsetY: 4), + ), + ), + Expanded( + child: TabBarView( + controller: tabCtr, + children: [ + SearchVideoResultPage( + key: ValueKey("0$keywords"), + keywords: keywords, + type: MediaStyle.Video) + .keepAlive, + SearchVideoResultPage( + key: ValueKey("1$keywords"), + keywords: keywords, + type: MediaStyle.Drama) + .keepAlive, + SearchVideoResultPage( + key: ValueKey("2$keywords"), + keywords: keywords, + type: MediaStyle.ShortVideo) + .keepAlive, + SearchACGResultPage( + key: ValueKey("3$keywords"), + keywords: keywords, + type: MediaStyle.Comics) + .keepAlive, + SearchACGResultPage( + key: ValueKey("4$keywords"), + keywords: keywords, + type: MediaStyle.Cartoon) + .keepAlive, + SearchVideoResultPage( + key: ValueKey("5$keywords"), + keywords: keywords, + type: MediaStyle.Community) + .keepAlive, + SearchVideoResultPage( + key: ValueKey("6$keywords"), + keywords: keywords, + type: MediaStyle.Pic) + .keepAlive, + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/home/search_page/search_result_page/search_video_result_page.dart b/lib/hj_page/home/search_page/search_result_page/search_video_result_page.dart new file mode 100644 index 0000000..e70e29a --- /dev/null +++ b/lib/hj_page/home/search_page/search_result_page/search_video_result_page.dart @@ -0,0 +1,421 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/drama_media_info.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/drama/drama_detail_page.dart'; +import 'package:hgdj/hj_page/drama/drama_list_page.dart'; +import 'package:hgdj/hj_utils/api_service/drama_service.dart'; +import 'package:hgdj/hj_utils/api_service/search_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../../hj_model/home/video_list_model.dart'; +import '../../../../tools_base/refresh/pull_refresh.dart'; +import '../../../../tools_base/widget/multitap_recognizer.dart'; +import '../../../cartoon/photo_gallery_item.dart'; +import '../../../community/widget/community_post_widget.dart'; +import '../../home_cell_style/video_simple_cell.dart'; +import '../../home_cell_style/divider_tab_bar.dart'; +import '../../tag/video_tag_page.dart'; + +//视频搜索结果排序 tab(标题与接口 sort 值绑定) +const _sortTabs = [ + SortTab('最多收藏', 3), + SortTab('最新上架', 2), + SortTab('最多观看', 1), +]; + +class SearchVideoResultPage extends StatefulWidget { + final String keywords; + final MediaStyle type; + + SearchVideoResultPage({ + super.key, + required this.type, + required this.keywords, + }); + + @override + State createState() => _SearchVideoResultPageState(); +} + +class _SearchVideoResultPageState extends State { + RefreshController? refreshController; //由 pullYsRefresh 的 onInit 注入,组件负责释放 + int pageNumber = 1; + List? videoList; + List? tagVidList; + String? tagID; + int sortIndex = 0; + + bool get _isDrama => widget.type == MediaStyle.Drama; + + int get sortParam => _sortTabs[sortIndex].sort; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _loadData(sortParam); + }); + } + + _loadData(int sortValue, {int page = 1, int size = 12}) async { + try { + if (_isDrama) { + final retResp = await DramaService.search( + keyword: widget.keywords, + page: page, + size: size, + ); + pageNumber = page; + if (page == 1) { + videoList = []; + tagVidList = + retResp?.tagMediaList.map((e) => e.toVideoModel(null)).toList() ?? + []; + tagID = retResp?.tagID; + } + videoList ??= []; + videoList?.addAll(retResp?.list.map((e) => e.toVideoModel(null)) ?? []); + retResp?.hasNext != true + ? refreshController?.loadNoData() + : refreshController?.loadComplete(); + } else { + VideoListResp? retResp = await SearchService.searchMedia( + widget.keywords, + pageNumber: page, + pageSize: size, + realm: widget.type.searchRealm, + sortType: sortValue); + pageNumber = page; + videoList ??= []; + videoList?.addAll(retResp?.videos ?? []); + if (page == 1) { + tagVidList = retResp?.tagVidList ?? []; + tagID = retResp?.tagID; + } + retResp?.hasNext == false + ? refreshController?.loadNoData() + : refreshController?.loadComplete(); + } + } catch (e) { + refreshController?.loadComplete(); + debugLog(e); + } + + refreshController?.refreshCompleted(); + videoList ??= []; + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + if (videoList == null && tagVidList == null) { + return LoadingCenterWidget(); + } else if (videoList?.isEmpty == true && tagVidList?.isEmpty == true) { + return CErrorWidget( + retryOnTap: () { + videoList = null; + setState(() {}); + _loadData(sortParam); + }, + ); + } else { + return NestedScrollView( + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverToBoxAdapter( + key: ValueKey(tagID), + child: _buildTagWidget(), + ) + ]; + }, + body: Column( + children: [ + if (widget.type == MediaStyle.Video || + widget.type == MediaStyle.ShortVideo) + DividerTabBar( + _sortTabs.map((e) => e.name).toList(), + selectIndex: sortIndex, + alignment: Alignment.center, + callback: (value) { + sortIndex = value; + videoList = null; + _loadData(sortParam); + setState(() {}); + }, + ), + Expanded( + child: _buildContent(), + ), + ], + ), + ); + } + } + + Widget _buildContent() { + if (videoList == null) { + return LoadingCenterWidget(); + } else if (videoList?.isEmpty == true) { + return CErrorWidget( + retryOnTap: () { + videoList = null; + setState(() {}); + _loadData(sortParam); + }, + ); + } else { + return pullYsRefresh( + onInit: (ctr) => refreshController = ctr, + onLoading: (ctr) => _loadData(sortParam, page: pageNumber + 1), + onRefresh: (ctr) => _loadData(sortParam), + child: _buildTypeList(), + ); + } + } + + /// 进短剧二级页;搜索列表不一定带 dramaInfo,最少用 id/标题/封面兜底 + void _openDrama(VideoModel model) { + final drama = model.dramaInfo ?? + (DramaMediaInfo() + ..id = model.id + ..title = model.title + ..verticalCover = model.cover + ..horizontalCover = model.cover + ..totalEpisode = model.totalEpisode); + Get.to(() => DramaDetailPage(drama: drama)); + } + + Widget _buildTypeList() { + if (widget.type == MediaStyle.Drama) { + return GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: 168 / 266, + ), + itemCount: videoList?.length ?? 0, + itemBuilder: (context, index) { + final model = videoList![index]; + return MultiTap( + onTap: () => _openDrama(model), + child: VideoSimpleCell( + videoModel: model, + textLines: 1, + coverRightText: model.dramaInfo?.episodeNumberStatus, + showLevelIcon: false, + isFromSearch: true, + onTap: () => _openDrama(model), + ), + ); + }, + ); + } + if (widget.type == MediaStyle.Video || + widget.type == MediaStyle.ShortVideo) { + bool isShort = widget.type == MediaStyle.ShortVideo; + return GridView.builder( + padding: EdgeInsets.only(left: 12, right: 12), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isShort ? 3 : 2, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: isShort ? 111 / 190 : 168 / 154, + ), + itemCount: videoList?.length ?? 0, + itemBuilder: (context, index) { + return VideoSimpleCell( + videoModel: videoList![index], + textLines: isShort ? 1 : 2, + isFromSearch: true, + ); + }, + ); + } else if (widget.type == MediaStyle.Pic) { + return GridView.builder( + padding: EdgeInsets.only(left: 12, right: 12), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: 111 / 190, + ), + itemCount: videoList?.length ?? 0, + itemBuilder: (context, index) { + return PhotoGalleryItem(videoModel: videoList![index], textline: 1); + }, + ); + } else { + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: videoList?.length ?? 0, + itemBuilder: (context, index) { + return CommunityPostWidget( + videoModel: videoList![index], + videoModels: videoList, + ); + }, + ); + } + } + + Widget _buildTagWidget() { + if (tagVidList?.isNotEmpty != true) return SizedBox(); + if (_isDrama) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.keywords} 标签内容', + style: const TextStyle(color: Color(0xE5FFFFFF), fontSize: 14), + ), + 12.sizeBoxH, + GridView.builder( + itemCount: min(4, tagVidList?.length ?? 0), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: 168 / 266, + ), + itemBuilder: (context, index) { + final videoModel = tagVidList![index]; + return MultiTap( + onTap: () => _openDrama(videoModel), + child: VideoSimpleCell( + videoModel: videoModel, + textLines: 1, + coverRightText: videoModel.dramaInfo?.episodeNumberStatus, + showLevelIcon: false, + isFromSearch: true, + onTap: () => _openDrama(videoModel), + ), + ); + }, + ), + if (tagID?.isNotEmpty == true) ...[ + const SizedBox(height: 12), + GestureDetector( + onTap: () => DramaListPage.toTag( + TagsBean(id: tagID, name: widget.keywords)), + child: Container( + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x1AFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('查看更多', + style: TextStyle( + color: Color(0xffEFEFEF), fontSize: 14)), + SizedBox(width: 6), + Icon(Icons.keyboard_arrow_right, + color: Color(0xffDCDCDC), size: 18), + ], + ), + ), + ), + ], + const SizedBox(height: 6), + ], + ), + ); + } + if (widget.type == MediaStyle.Video || + widget.type == MediaStyle.ShortVideo) { + bool isShort = widget.type == MediaStyle.ShortVideo; + return Container( + padding: EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${widget.keywords} 标签内容", + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 14, + ), + ), + 12.sizeBoxH, + GridView.builder( + itemCount: min(isShort ? 6 : 4, tagVidList?.length ?? 0), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isShort ? 3 : 2, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: isShort ? 111 / 190 : 168 / 154, + ), + itemBuilder: (context, index) { + VideoModel videoModel = tagVidList![index]; + return VideoSimpleCell( + videoModel: videoModel, + textLines: isShort ? 1 : 2, + isFromSearch: true, + ); + }, + ), + SizedBox(height: 12), + GestureDetector( + onTap: () { + Get.to( + VideoTagPage( + TagsBean(id: tagID, name: widget.keywords), + isFromSearch: true, + isShortStyle: widget.type == MediaStyle.ShortVideo, + ), + preventDuplicates: false); + }, + child: Container( + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Color(0x1AFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "查看更多", + style: TextStyle( + color: Color(0xffEFEFEF), + fontSize: 14, + ), + ), + SizedBox(width: 6), + Icon( + Icons.keyboard_arrow_right, + color: Color(0xffDCDCDC), + size: 18, + ), + ], + ), + ), + ), + SizedBox(height: 6), + ], + ), + ); + } + return SizedBox(); + } +} diff --git a/lib/hj_page/home/search_page/video_all_type_logic.dart b/lib/hj_page/home/search_page/video_all_type_logic.dart new file mode 100644 index 0000000..f855d19 --- /dev/null +++ b/lib/hj_page/home/search_page/video_all_type_logic.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../hj_model/cartoon_media_info.dart'; +import '../../../hj_model/home/video_library_model.dart'; +import '../../../hj_model/home/module_detail_model.dart'; +import '../../../hj_model/video_model.dart'; +import '../../../hj_utils/api_service/vid_service.dart'; +import '../../../tools_base/debug_log.dart'; +import '../../../tools_base/loading/loading_alert_widget.dart'; + +class VideoAllTypeLogic extends GetxController with GetSingleTickerProviderStateMixin { + VideoAllTypeLogic(); + + HomeVideoLibrary? homeVideoLibrary; + TimeType? firstSelectedType; + TimeType? secondSelectedType; + Tags? thirdSelectedType; + TimeType? forthSelectedType; + TimeType? fivthSelectedType; + RefreshController? refreshController; + List? searchVideoList; + List? searchACGList; + Keyword? preKeyword; + int currentPage = 1; + RxBool isInitData = true.obs; + RxBool showFloatingTags = false.obs; + + List? get tags { + if (firstSelectedType?.isACG == true) { + return homeVideoLibrary?.acgTags; + } else { + return homeVideoLibrary?.vidTags; + } + } + + String get selectedTagsText { + // 上滑吸顶摘要:展示分类/排序/标签/时间四个维度的当前选中值;paymentType 无入口不参与。 + // 「全部」为默认兜底选项,摘要里省略避免多出一段无意义文案。 + final names = [ + firstSelectedType?.name, + secondSelectedType?.name, + thirdSelectedType?.name, + fivthSelectedType?.name, + ]; + return names.whereType().where((e) => e.isNotEmpty && e != '全部').join('·'); + } + + Keyword get keywordValue { + Keyword keyword = Keyword(); + keyword.tags = thirdSelectedType; + keyword.canvas = firstSelectedType; + keyword.orderBy = secondSelectedType; + keyword.paymentType = forthSelectedType; + keyword.timeType = fivthSelectedType; + return keyword; + } + + @override + void onInit() { + super.onInit(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _loadMenuData(); + }); + } + + void reloadMenuData() { + isInitData.value = true; + _loadMenuData(); + } + + void _loadMenuData() async { + try { + homeVideoLibrary = await VidService.fetchLibrary(); + firstSelectedType = homeVideoLibrary?.canvas?.first; + secondSelectedType = homeVideoLibrary?.orderBy?.first; + thirdSelectedType = homeVideoLibrary?.vidTags?.first; + forthSelectedType = homeVideoLibrary?.paymentType?.first; + fivthSelectedType = homeVideoLibrary?.timeType?.first; + _loadSearchData(); + } catch (e) { + debugLog(e); + } + homeVideoLibrary ??= HomeVideoLibrary(); + isInitData.value = false; + update(); + } + + void reloadData() { + searchVideoList = null; + update(); + _loadSearchData(); + } + + void refreshData() { + _loadSearchData(); + } + + void loadMoreData() { + _loadSearchData(page: currentPage + 1); + } + + void menuExchangeEvent() { + update(); + _loadSearchData(isSelectMenu: true); + } + + void _loadSearchData({int page = 1, int size = 10, bool isSelectMenu = false}) async { + try { + if (isSelectMenu) { + LoadingAlertWidget.show(); + } + Keyword keywordParam = keywordValue; + HomeVideoLibraryResult? respResult = await VidService.searchLibrary( + page, + size, + filterMenu: keywordParam, + ); + if (keywordValue.modelKey != keywordParam.modelKey) { + return; + } + if (isSelectMenu) { + LoadingAlertWidget.cancel(); + } + searchVideoList ??= []; + searchACGList ??= []; + currentPage = page; + if (page == 1) { + searchVideoList?.clear(); + searchACGList?.clear(); + } + searchVideoList?.addAll(respResult?.list ?? []); + searchACGList?.addAll(respResult?.allMediaList ?? []); + respResult?.hasNext == true ? refreshController?.loadComplete() : refreshController?.loadNoData(); + } catch (e) { + if (isSelectMenu) { + LoadingAlertWidget.cancel(); + } + debugLog(e); + refreshController?.loadComplete(); + } + refreshController?.refreshCompleted(); + searchVideoList ??= []; + update(); + } +} diff --git a/lib/hj_page/home/search_page/video_all_type_page.dart b/lib/hj_page/home/search_page/video_all_type_page.dart new file mode 100644 index 0000000..97d4fac --- /dev/null +++ b/lib/hj_page/home/search_page/video_all_type_page.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_model/cartoon_media_info.dart'; +import 'package:waterfall_flow/waterfall_flow.dart'; + +import '../../../hj_model/home/video_library_model.dart'; +import '../../../hj_model/home/module_detail_model.dart'; +import '../../../hj_model/video_model.dart'; +import '../../../tools_base/loading/loading_center_widget.dart'; +import '../../../tools_base/refresh/pull_refresh.dart'; +import '../../cartoon/acg_widget_item.dart'; +import '../../cartoon/photo_gallery_item.dart'; +import '../../community/widget/community_post_widget.dart'; +import '../home_cell_style/video_simple_cell.dart'; +import 'video_all_type_logic.dart'; + +//片库 +class VideoAllTypePage extends StatefulWidget { + const VideoAllTypePage({super.key}); + + @override + State createState() => _VideoAllTypePageState(); +} + +class _VideoAllTypePageState extends State { + final ScrollController scrollController = ScrollController(); + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: VideoAllTypeLogic(), + builder: (logic) { + // 摘要文案随筛选变化,靠 GetBuilder 的 update() 重算;下方 Obx 只管吸顶显隐 + final tagsText = logic.selectedTagsText; + return Scaffold( + appBar: AppBar(title: const Text('片库')), + body: Stack( + children: [ + Obx( + () { + if (logic.isInitData.value) { + return LoadingCenterWidget(); + } else if (logic.homeVideoLibrary?.isNotEmpty != true) { + return CErrorWidget( + retryOnTap: () { + logic.reloadMenuData(); + }, + ); + } else { + return NotificationListener( + onNotification: (scrollInfo) { + if (scrollInfo.metrics.pixels > 200) { + logic.showFloatingTags.value = true; + } else { + logic.showFloatingTags.value = false; + } + return true; + }, + child: pullYsRefresh( + onInit: (ctr) => logic.refreshController = ctr, + onLoading: (_) => logic.loadMoreData(), + onRefresh: (_) => logic.refreshData(), + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: Column( + children: [ + _buildItemMenuWidget( + "分类", + logic.homeVideoLibrary?.canvas, + logic.firstSelectedType, (data) { + if (logic.firstSelectedType?.isACG != + data.isACG) { + logic.thirdSelectedType = null; + } + if (logic.firstSelectedType != data) { + logic.firstSelectedType = data; + } else { + logic.firstSelectedType = null; + } + logic.searchVideoList?.clear(); + logic.menuExchangeEvent(); + }), + _buildItemMenuWidget( + "排序", + logic.homeVideoLibrary?.orderBy, + logic.secondSelectedType, (data) { + if (logic.secondSelectedType != data) { + logic.secondSelectedType = data; + } else { + logic.secondSelectedType = null; + } + logic.menuExchangeEvent(); + }), + _buildTagMenuWidget( + "标签", logic.tags, logic.thirdSelectedType, + (data) { + if (logic.thirdSelectedType != data) { + logic.thirdSelectedType = data; + } else { + logic.thirdSelectedType = null; + } + logic.menuExchangeEvent(); + }), + _buildItemMenuWidget( + "时间", + logic.homeVideoLibrary?.timeType, + logic.fivthSelectedType, (data) { + if (logic.fivthSelectedType != data) { + logic.fivthSelectedType = data; + } else { + logic.fivthSelectedType = null; + } + logic.menuExchangeEvent(); + }), + ], + ), + ), + _buildTableContent(logic), + ], + ), + ), + ); + } + }, + ), + Obx(() { + if (!logic.showFloatingTags.value) return const SizedBox(); + if (tagsText.isEmpty) return const SizedBox(); + return Positioned( + top: 0, + left: 0, + right: 0, + child: GestureDetector( + onTap: () { + // 滚动到页面顶部 + if (scrollController.hasClients) { + scrollController.animateTo( + 0, + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + tagsText, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + }), + ], + ), + ); + }, + ); + } + + Widget _buildConditionItem(String? title, bool isSelected) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 6), + margin: EdgeInsets.only(right: 5), + alignment: Alignment.centerLeft, + child: Text( + title ?? "", + style: TextStyle( + color: isSelected ? AppColors.actionRed : Color(0x8CFFFFFF), + fontSize: 14, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, + ), + ), + ); + } + + Widget _buildTitleItem(String? typeName) { + return Text( + typeName ?? "", + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ); + } + + Widget _buildTableContent(VideoAllTypeLogic logic) { + if (logic.searchVideoList == null && logic.searchACGList == null) { + return SliverToBoxAdapter( + child: SizedBox( + height: 300, + child: LoadingCenterWidget(), + ), + ); + } else if (logic.searchVideoList?.isNotEmpty != true && + logic.searchACGList?.isNotEmpty != true) { + return SliverToBoxAdapter( + child: SizedBox( + height: 300, + child: CErrorWidget( + retryOnTap: () { + logic.reloadData(); + }, + ), + ), + ); + } else { + String typeName = logic.firstSelectedType?.name ?? ""; + if (typeName == "帖子") { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return CommunityPostWidget( + videoModel: logic.searchVideoList![index], + videoModels: logic.searchVideoList, + ); + }, + childCount: logic.searchVideoList?.length ?? 0, + ), + ); + } + if (typeName == "图集") { + return _buildWaterfall( + logic, (vm) => PhotoGalleryItem(videoModel: vm, textline: 1)); + } + if (typeName == "抖音" || logic.firstSelectedType?.key == "sp") { + return _buildWaterfall( + logic, (vm) => VideoSimpleCell(videoModel: vm, textLines: 1)); + } else if (typeName == "动漫" || typeName == "漫画") { + return SliverPadding( + padding: EdgeInsets.fromLTRB(12, 12, 12, 0), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 12.0, + crossAxisSpacing: 6, + childAspectRatio: 111 / 190, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + CartoonMediaInfo videoItem = logic.searchACGList![index]; + return AcgItemWidget(info: videoItem); + }, + childCount: logic.searchACGList?.length ?? 0, + ), + ), + ); + } else { + return SliverPadding( + padding: EdgeInsets.fromLTRB(12, 12, 12, 0), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12.0, + crossAxisSpacing: 6, + childAspectRatio: 168 / 154, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + VideoModel videoItem = logic.searchVideoList![index]; + return VideoSimpleCell( + videoModel: videoItem, + ); + }, + childCount: logic.searchVideoList?.length ?? 0, + ), + ), + ); + } + } + } + + //图集/抖音共用的 3 列瀑布流(仅 item 构建不同) + Widget _buildWaterfall( + VideoAllTypeLogic logic, Widget Function(VideoModel vm) itemBuilder) { + return SliverPadding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + sliver: SliverWaterfallFlow( + gridDelegate: const SliverWaterfallFlowDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 6, + mainAxisSpacing: 12, + ), + delegate: SliverChildBuilderDelegate( + (c, index) => AspectRatio( + aspectRatio: 111 / 190, + child: itemBuilder(logic.searchVideoList![index]), + ), + childCount: logic.searchVideoList?.length ?? 0, + ), + ), + ); + } + + Widget _buildItemMenuWidget(String typeName, List? listData, + TimeType? selectType, Function(TimeType timeType) callBack) { + selectType ??= listData?[0]; + return Container( + height: 40, + padding: EdgeInsets.only(left: 16), + child: Row( + children: [ + _buildTitleItem(typeName), + SizedBox(width: 12), + Expanded( + child: ListView.builder( + itemCount: listData?.length ?? 0, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(vertical: 8), + itemBuilder: (context, index) { + TimeType data = listData![index]; + bool isSelected = selectType?.name == data.name; + return InkWell( + enableFeedback: false, + onTap: () async { + callBack.call(data); + }, + child: _buildConditionItem(data.name, isSelected), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildTagMenuWidget(String typeName, List? listData, + Tags? selectType, Function(Tags timeType) callBack) { + selectType ??= listData?[0]; + return Container( + height: 40, + padding: EdgeInsets.only(left: 16), + child: Row( + children: [ + _buildTitleItem(typeName), + SizedBox(width: 12), + Expanded( + child: ListView.builder( + itemCount: listData?.length ?? 0, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(vertical: 8), + itemBuilder: (context, index) { + Tags data = listData![index]; + bool isSelected = selectType?.name == data.name; + return GestureDetector( + onTap: () async { + callBack.call(data); + }, + child: _buildConditionItem(data.name, isSelected), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/home/search_page/widget/common_search_widget.dart b/lib/hj_page/home/search_page/widget/common_search_widget.dart new file mode 100644 index 0000000..09a323a --- /dev/null +++ b/lib/hj_page/home/search_page/widget/common_search_widget.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../../tools_base/widget/marquee_widget.dart'; +import '../../../mine/welfare/sign_daily_page.dart'; +import '../../home_main_logic.dart'; +import '../search_main_page.dart'; + +class CommonSearchBarView extends StatefulWidget { + final HomeMainLogic? logic; + + const CommonSearchBarView({super.key, this.logic}); + + @override + State createState() => _CommonSearchBarViewState(); +} + +class _CommonSearchBarViewState extends State { + /// 当前轮播展示的搜索热词,点击搜索框时带入搜索页 + String? _searchText; + + static final _hintStyle = + TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 14); + + @override + void initState() { + super.initState(); + if (Config.searchHints.isNotEmpty) _searchText = Config.searchHints.first; + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + 16.w.sizeBoxW, + Image.asset('place_holder_logo.webp'.commonImgPath, height: 28), + 8.sizeBoxW, + Expanded(child: _buildSearchBox()), + 12.sizeBoxW, + if (Config.signIcon != null && Config.signIcon!.isNotEmpty) + _buildSignIcon(), + 12.sizeBoxW, + _buildMenuIcon(), + 16.sizeBoxW, + ], + ); + } + + /// 中间搜索框(热词轮播) + Widget _buildSearchBox() { + return InkWell( + enableFeedback: false, + onTap: () => Get.to(SearchMainPage(searchText: _searchText)), + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xff1E1C1D), + borderRadius: BorderRadius.circular(99), + ), + child: Config.searchHints.isEmpty + ? Text('请输入关键字', style: _hintStyle) + : MarqueeWidget( + count: Config.searchHints.length, + onIndexChanged: (index) => + _searchText = Config.searchHints[index], + itemBuilder: (_, index) => Container( + alignment: Alignment.centerLeft, + child: Text(Config.searchHints[index], style: _hintStyle), + ), + ), + ), + ); + } + + /// 签到入口 + Widget _buildSignIcon() { + return InkWell( + enableFeedback: false, + onTap: () => Get.to(SignDailyPage()), + child: NetworkImageLoader( + imageUrl: Config.signIcon, + width: 20, + borderRadius: 0, + placeHolderWidget: const SizedBox(), + ), + ); + } + + /// 右侧菜单入口 + Widget _buildMenuIcon() { + return InkWell( + enableFeedback: false, + onTap: () => widget.logic?.openEndDrawer(), + child: Image.asset('acg_menu.png'.acgImgPath, + width: 24, color: const Color(0xff989898)), + ); + } +} diff --git a/lib/hj_page/home/search_page/widget/search_app_bar.dart b/lib/hj_page/home/search_page/widget/search_app_bar.dart new file mode 100644 index 0000000..e8f58ee --- /dev/null +++ b/lib/hj_page/home/search_page/widget/search_app_bar.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../video_all_type_page.dart'; + +final class SearchAppBar extends StatefulWidget { + final Function(String keywork) onSubmitted; + final bool? isClose; + final String? searchText; + + /// 外部持有的输入控制器:搜索主页由 logic 持有,点热搜词/历史项后好回填。 + /// 不传则本组件自建自销——每个 SearchAppBar 各用各的,不再共享全局单例 + final TextEditingController? controller; + + const SearchAppBar({ + super.key, + required this.onSubmitted, + this.searchText, + this.isClose, + this.controller, + }); + + @override + State createState() => _SearchAppBarState(); +} + +class _SearchAppBarState extends State { + late final searchTextCtr = widget.controller ?? TextEditingController(); + late bool showDeleteIcon; + + @override + void initState() { + super.initState(); + final initText = widget.searchText ?? ""; + showDeleteIcon = initText.isNotEmpty; + // 控制器不再跨路由共享,此处赋值不会 markNeedsBuild 别的路由,无需延到首帧后 + searchTextCtr.text = initText; + } + + @override + void dispose() { + // 只释放自己 new 的;外部传进来的归调用方管 + if (widget.controller == null) searchTextCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only(top: screen.paddingTop), + color: Colors.black, + child: Container( + height: 56, + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + GestureDetector( + onTap: () { + setState(() { + showDeleteIcon = false; + searchTextCtr.text = ""; + }); + Get.back(); + }, + child: Image.asset( + 'common_back.png'.commonImgPath, + width: 22, + ), + ), + 10.sizeBoxW, + Expanded( + child: Container( + height: 32, + padding: EdgeInsets.symmetric(horizontal: 10), + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: Color(0x11FFFFFF), + borderRadius: BorderRadius.circular(20)), + child: Row( + children: [ + InkWell( + enableFeedback: false, + onTap: () { + Get.to(() => VideoAllTypePage(), opaque: false); + }, + child: Row( + children: [ + 2.sizeBoxW, + Image.asset('libary_search.webp'.homePath, width: 20), + 6.sizeBoxW, + Text( + '片库', + style: TextStyle( + color: Color(0xFFF68804), + fontSize: 14, + ), + ), + 6.sizeBoxW, + Container( + height: 12, + width: 1, + color: Colors.white.withValues(alpha: .05), + ), + 6.sizeBoxW, + ], + ), + ), + Expanded( + child: TextField( + controller: searchTextCtr, + style: TextStyle( + color: Colors.white.withValues(alpha: .8), + fontSize: 14, + height: 1), + onSubmitted: (value) => widget.onSubmitted(value), + onChanged: (value) { + if (value.isNotEmpty == true) { + setState(() { + showDeleteIcon = true; + }); + } else { + setState(() { + showDeleteIcon = false; + }); + } + }, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: '搜索关键词', + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 14, + height: 1), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + 12.sizeBoxW, + Visibility( + visible: showDeleteIcon, + child: GestureDetector( + onTap: () { + setState(() { + searchTextCtr.text = ""; + showDeleteIcon = false; + }); + }, + child: Image.asset('search_close.webp'.homePath, + width: 18), + ), + ) + ], + ), + ), + ), + 10.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: () { + if (widget.isClose == true) { + setState(() { + showDeleteIcon = false; + }); + } + if (searchTextCtr.text.isEmpty == true) { + showToast("请输入关键字"); + return; + } + widget.onSubmitted(searchTextCtr.text); + }, + child: Text( + "搜索", + style: TextStyle( + color: Color(0x73FFFFFF), + fontSize: 16, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/home/search_page/widget/search_history_view.dart b/lib/hj_page/home/search_page/widget/search_history_view.dart new file mode 100644 index 0000000..733c946 --- /dev/null +++ b/lib/hj_page/home/search_page/widget/search_history_view.dart @@ -0,0 +1,137 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +/// 搜索历史区:纯展示,数据与增删由 SearchMainLogic 持有 +class SearchHistoryView extends StatefulWidget { + const SearchHistoryView({ + super.key, + required this.histories, + required this.onHistoryClick, + required this.onClearAll, + }); + + final List histories; + final Function(String keyword) onHistoryClick; + final VoidCallback onClearAll; + + @override + State createState() => _SearchHistoryViewState(); +} + +class _SearchHistoryViewState extends State { + /// 折叠状态最多展示几条 + static const _collapsedMax = 8; + + bool isExpandData = false; + + int get itemCount { + final total = widget.histories.length; + return isExpandData ? total : min(_collapsedMax, total); + } + + @override + Widget build(BuildContext context) { + if (widget.histories.isEmpty) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.only(left: 12, right: 12, top: 12), + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '历史记录', + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + InkWell( + enableFeedback: false, + onTap: widget.onClearAll, + child: Container( + padding: const EdgeInsets.all(1), + child: Image.asset("history_delete.webp".homePath, + width: 18, height: 18), + ), + ), + ], + ), + 12.sizeBoxH, + GridView.builder( + padding: EdgeInsets.zero, + itemCount: itemCount, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + childAspectRatio: 79 / 34, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + ), + itemBuilder: (_, index) => + _buildHistoryItem(widget.histories[index]), + ), + _buildMoreRecord(), + ], + ), + ); + } + + Widget _buildHistoryItem(String tag) { + return GestureDetector( + onTap: () => widget.onHistoryClick(tag), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + tag, + maxLines: 1, + style: const TextStyle(color: Color(0xE5FFFFFF), fontSize: 12), + ), + ), + ); + } + + /// 「查看 / 收起完整记录」按钮:少于 [_collapsedMax] 条时不显示 + Widget _buildMoreRecord() { + if (widget.histories.length < _collapsedMax) { + return const SizedBox(); + } + return Container( + padding: const EdgeInsets.only(top: 12), + alignment: Alignment.topCenter, + child: InkWell( + enableFeedback: false, + onTap: () => setState(() => isExpandData = !isExpandData), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isExpandData ? "收起完整记录" : "查看完整记录", + style: const TextStyle(color: Color(0xff989898), fontSize: 12), + ), + 4.sizeBoxW, + Transform.rotate( + angle: isExpandData ? pi : 0, + child: Image.asset( + "arrow_down.png".commonImgPath, + width: 12, + color: const Color(0xffDCDCDC), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/home/search_page/widget/search_hot_tag_view.dart b/lib/hj_page/home/search_page/widget/search_hot_tag_view.dart new file mode 100644 index 0000000..bad9209 --- /dev/null +++ b/lib/hj_page/home/search_page/widget/search_hot_tag_view.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +class SearchHotTagView extends StatelessWidget { + final Function(String keyword) onTagClick; + + const SearchHotTagView({super.key, required this.onTagClick}); + + @override + Widget build(BuildContext context) { + final tags = Config.hotWords; + if (tags.isEmpty) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.only(left: 12, right: 12, top: 18), + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '大家都在搜', + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + 12.h.sizeBoxH, + GridView.builder( + padding: EdgeInsets.zero, + itemCount: tags.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + childAspectRatio: 79 / 34, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + ), + itemBuilder: (_, index) => _buildTagItem(tags[index]), + ), + ], + ), + ); + } + + Widget _buildTagItem(String tag) { + return GestureDetector( + onTap: () => onTagClick(tag), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + tag, + maxLines: 1, + style: const TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 12, + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/home/search_page/widget/topic_item.dart b/lib/hj_page/home/search_page/widget/topic_item.dart new file mode 100644 index 0000000..fe8b509 --- /dev/null +++ b/lib/hj_page/home/search_page/widget/topic_item.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../community/community_tag_page/community_tag_page.dart'; + +class TopicItem extends StatelessWidget { + final TagsBean model; + final bool isSelect; + final Function()? onTap; + + const TopicItem( + {super.key, required this.model, this.onTap, this.isSelect = false}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + if (onTap != null) { + onTap!(); + } else { + Get.to(() => CommunityTagDetailPage(model: model)); + } + }, + child: Container( + height: 66, + padding: EdgeInsets.only(left: 12.w, right: 12.w), + decoration: BoxDecoration( + color: Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + NetworkImageLoader( + imageUrl: model.coverImg ?? '', + width: 38, + height: 38, + borderRadius: 3, + ), + 10.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '#${model.name}', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // 4.sizeBoxH, + Text( + '${model.vidCount?.countStr}个帖子 ${model.playCount.countStr}浏览 ${model.followCount.countStr}关注', + style: TextStyle(color: Color(0x73FFFFFF), fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + 12.sizeBoxW, + Container( + width: 52, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isSelect ? AppColors.actionRed : Color(0x33FFFFFF), + borderRadius: BorderRadius.circular(30), + ), + child: Text( + isSelect ? '已选' : "选择", + style: TextStyle( + color: isSelect ? Color(0xffffffff) : Color(0xffDCDCDC), + fontSize: 14, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/home/section_all_page/section_all_logic.dart b/lib/hj_page/home/section_all_page/section_all_logic.dart new file mode 100644 index 0000000..1a64aff --- /dev/null +++ b/lib/hj_page/home/section_all_page/section_all_logic.dart @@ -0,0 +1,32 @@ +import 'package:hgdj/tools_base/base_list_controller.dart'; + +import '../../../hj_model/home/module_detail_model.dart'; +import '../../../hj_utils/api_service/vid_service.dart'; + +class SectionAllLogic extends ListBaseLogic { + final String id; + + SectionAllLogic(this.id); + + @override + void onInit() { + super.onInit(); + loadData(); + } + + //isRefresh 下拉刷新/加载更多;showLoading 重试时先清空显示 loading + void loadData({bool isRefresh = true, bool showLoading = false}) { + if (showLoading) { + dataList = null; + update(); + } + fetchData(isRefresh: isRefresh, fetch: _fetch); + } + + Future<(List?, bool)> _fetch(int page) async { + final resp = + await VidService.fetchSectionAll(id, pageNumber: page, pageSize: 20); + //hasNext 为 null 时按"有更多"处理(保持原 == false 判断语义) + return (resp?.list, resp?.hasNext != false); + } +} diff --git a/lib/hj_page/home/section_all_page/section_all_page.dart b/lib/hj_page/home/section_all_page/section_all_page.dart new file mode 100644 index 0000000..a678f84 --- /dev/null +++ b/lib/hj_page/home/section_all_page/section_all_page.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../hj_model/home/module_detail_model.dart'; +import '../special_topic_detail/special_topics_detail_page.dart'; +import 'section_all_logic.dart'; + +class SectionAllPage extends StatelessWidget { + final String id; + + const SectionAllPage(this.id, {super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: SectionAllLogic(id), + builder: (logic) => Scaffold( + appBar: AppBar(title: Text("原创达人")), + body: pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onRefresh: (ctr) => logic.loadData(), + onLoading: (ctr) => logic.loadData(isRefresh: false), + child: () { + if (logic.isLoading) { + return LoadingCenterWidget(); + } else if (logic.isEmptyData) { + return CErrorWidget( + retryOnTap: () => logic.loadData(showLoading: true), + ); + } else { + final list = logic.dataList!; + return ListView.builder( + padding: EdgeInsets.fromLTRB(16, 12, 16, 0), + itemExtent: 62, //item 固定高 50 + 底部 margin 12,跳过逐项测量 + itemCount: list.length, + itemBuilder: (context, index) { + AllSection model = list[index]; + return InkWell( + enableFeedback: false, + onTap: () { + Get.to(SpecialTopicsDetailPage(model), + preventDuplicates: false); + }, + child: Container( + margin: EdgeInsets.only(bottom: 12), + height: 50, + child: Row( + children: [ + NetworkImageLoader( + imageUrl: model.sectionCover, + width: 50, + height: 50, + borderRadius: 50, + ), + 12.sizeBoxW, + Expanded( + child: Text( + model.sectionName ?? "", + maxLines: 1, + style: TextStyle( + color: Color(0xffFFFFFF), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + 12.sizeBoxW, + Icon(Icons.keyboard_arrow_right, + color: Colors.white, size: 16), + ], + ), + ), + ); + }, + ); + } + }(), + ), + ), + ); + } +} diff --git a/lib/hj_page/home/special_topic_detail/special_topics_detail_logic.dart b/lib/hj_page/home/special_topic_detail/special_topics_detail_logic.dart new file mode 100644 index 0000000..044835c --- /dev/null +++ b/lib/hj_page/home/special_topic_detail/special_topics_detail_logic.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/home/module_detail_model.dart'; +import 'package:hgdj/hj_model/splash/ads_model.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; +import 'package:hgdj/tools_base/ad_manager.dart'; + +import '../home_cell_style/home_section_cell.dart'; + +/// 分页/刷新/加载态/防重入/异常兜底都交给 ListBaseLogic,本类只管排序切换与广告插入 +class SpecialTopicsLogic extends ListBaseLogic { + final AllSection? originModel; + SpecialTopicsLogic(this.originModel); + + // ========== 配置 ========== + static const _sortTabs = [ + SortTab('热门推荐', 'like'), + SortTab('最新上架', 'new'), + SortTab('最新热评', 'comment'), + ]; + + /// 走竖图布局的 showType(后端 2xx 一档) + static const _verticalTypes = { + HomeSectionShowType.verticalScroll25, + HomeSectionShowType.verticalFourGrid, + HomeSectionShowType.verticalSixGrid, + HomeSectionShowType.verticalNineGrid, + HomeSectionShowType.verticalListGrid, + }; + + List get sortTitles => _sortTabs.map((e) => e.name).toList(); + + // ========== 状态 ========== + int sort = 0; + + /// 广告只取一次,之后每页复用(只表示「取过」,取到空也算取过) + bool _adsLoaded = false; + final List _adsList = []; + + // ========== Controllers ========== + late final TabController tabCtr = + TabController(length: _sortTabs.length, vsync: this); + + // ========== 派生 getter ========== + String get title => originModel?.sectionName ?? ''; + + /// 竖向 cell 布局(封面 168:266 竖图)— 否则横向 cell(191:174) + /// late final:originModel 不会变,避免 textLines 在 itemBuilder 里被每个 cell 触发一次线性查找 + late final bool isVertical = _verticalTypes.contains( + HomeSectionShowType.values.firstWhere( + (e) => e.value == originModel?.showType, + orElse: () => HomeSectionShowType.oneLargeAndFourSmall, + ), + ); + + int get crossAxisCount => isVertical ? 3 : 2; + double get childAspectRatio => isVertical ? (168 / 266) : (191 / 174); + int get textLines => isVertical ? 1 : 2; + + // ========== 生命周期 ========== + @override + void onReady() { + super.onReady(); + loadData(); + } + + @override + void onClose() { + tabCtr.dispose(); + super.onClose(); + } + + // ========== 公开方法 ========== + /// [isRefresh] 刷新(回第 1 页并清空)/ 加载更多;[showLoading] 切排序时先转圈 + /// 广告要等基类把新页并入 dataList 后再按全量列表重排,所以 await 完再插 + Future loadData( + {bool isRefresh = true, bool showLoading = false}) async { + if (showLoading) { + dataList = null; // 置空即 isLoading + update(); + } + await fetchData(isRefresh: isRefresh, fetch: _fetch); + _insertAds(); + update(); + } + + // ========== 私有方法 ========== + Future<(List?, bool)> _fetch(int page) async { + final res = await VidService.fetchSectionVideos( + originModel?.sectionID ?? '', + pageNumber: page, + sortType: _sortTabs[sort].sort, + ); + // hasNext 为 null 按「没有更多」处理,保持原 == true 的判断语义 + return (res?.videos, res?.hasNext == true); + } + + /// 往列表里插广告。AdManager().adsByType 是纯内存读、无网络请求, + /// 整条链同步即可。insertGroupAds 内部先清旧广告位再重排,全量列表上重复调用是安全的。 + void _insertAds() { + if (!_adsLoaded) { + _adsList + ..clear() + ..addAll(AdManager().adsByType(8)); + _adsLoaded = true; + } + final list = dataList; + if (_adsList.isEmpty || list == null) return; + // 必须用 videoArr:VideoSimpleCell 只认 isAdsArr()(adsInfoArr); + // 用 .video 塞的是 randomAdsInfo,cell 不识别会渲染成空白视频(91PORN 占位 + 评论 null) + AdManager().insertGroupAds(list, _adsList, adGap: 8); + } +} diff --git a/lib/hj_page/home/special_topic_detail/special_topics_detail_page.dart b/lib/hj_page/home/special_topic_detail/special_topics_detail_page.dart new file mode 100644 index 0000000..c61597f --- /dev/null +++ b/lib/hj_page/home/special_topic_detail/special_topics_detail_page.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../../../hj_model/home/module_detail_model.dart'; +import '../home_cell_style/video_simple_cell.dart'; +import 'special_topics_detail_logic.dart'; + +class SpecialTopicsDetailPage extends StatelessWidget { + final AllSection? originModel; + + const SpecialTopicsDetailPage(this.originModel, {super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: SpecialTopicsLogic(originModel), + builder: (logic) => Scaffold( + appBar: AppBar(title: Text(logic.title)), + body: Column( + children: [ + _sortBar(logic), + Expanded( + child: pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onRefresh: (_) => logic.loadData(), + onLoading: (_) => logic.loadData(isRefresh: false), + child: _content(logic), + ), + ), + ], + ), + ), + ); + } + + Widget _sortBar(SpecialTopicsLogic logic) { + final titles = logic.sortTitles; + return Container( + height: 32, + margin: const EdgeInsets.symmetric(horizontal: 32, vertical: 6), + child: TabBar( + controller: logic.tabCtr, + tabAlignment: TabAlignment.fill, + isScrollable: false, + labelPadding: EdgeInsets.zero, + padding: EdgeInsets.zero, + labelStyle: const TextStyle(fontSize: 14), + labelColor: const Color(0xE5FFFFFF), + unselectedLabelStyle: const TextStyle(fontSize: 14), + unselectedLabelColor: const Color(0x73FFFFFF), + indicator: const BoxDecoration(), + onTap: (index) { + logic.sort = index; + logic.loadData(showLoading: true); + }, + tabs: [ + for (var i = 0; i < titles.length; i++) + _sortTab(titles[i], isLast: i == titles.length - 1), + ], + ), + ); + } + + Widget _sortTab(String title, {required bool isLast}) { + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border( + right: BorderSide( + color: isLast ? Colors.transparent : const Color(0x12FFFFFF), + width: 1, + ), + ), + ), + child: Text(title), + ); + } + + /// loading / 空态 / 视频网格 + Widget _content(SpecialTopicsLogic logic) { + if (logic.isLoading) return const LoadingCenterWidget(); + if (logic.isEmptyData) return const CErrorWidget(); + final list = logic.dataList!; // 上面两个判空已保证非空 + return GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 10), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: logic.crossAxisCount, + crossAxisSpacing: 7, + mainAxisSpacing: 12, + childAspectRatio: logic.childAspectRatio, + ), + itemCount: list.length, + itemBuilder: (context, index) => SizedBox.expand( + child: VideoSimpleCell( + videoModel: list[index], + textLines: logic.textLines, + ), + ), + ); + } +} diff --git a/lib/hj_page/home/tag/cartoon_tag_logic.dart b/lib/hj_page/home/tag/cartoon_tag_logic.dart new file mode 100644 index 0000000..376216d --- /dev/null +++ b/lib/hj_page/home/tag/cartoon_tag_logic.dart @@ -0,0 +1,73 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/acg_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../hj_model/cartoon_media_info.dart'; + +//专题版排序(sort: 收藏3 上架1 喜欢2) +const _sectionSortTabs = [ + SortTab('最多收藏', 3), + SortTab('最新上架', 1), + SortTab('最多喜欢', 2), +]; +//普通版排序(sort: 收藏3 上架2 喜欢1) +const _normalSortTabs = [ + SortTab('最多收藏', 3), + SortTab('最新上架', 2), + SortTab('最多喜欢', 1), +]; + +class CartoonTagLogic extends GetxController { + final String? sId; + final bool isSection; // true: 专题 + CartoonTagLogic(this.sId, this.isSection); + + int page = 1; + bool isLoading = true; + RefreshController? refreshController; + List dataSource = []; + int sortIndex = 0; + List> get _sortTabs => + isSection ? _sectionSortTabs : _normalSortTabs; + List get sorts => _sortTabs.map((e) => e.name).toList(); + + @override + void onInit() { + fetchPageData(); + super.onInit(); + } + + fetchPageData({bool isRefresh = true, bool showLoading = false}) async { + if (isRefresh) { + page = 1; + } + if (showLoading) { + isLoading = true; + update(); + } + final res = await ACGService.getMoreCartoon( + page, 12, _sortTabs[sortIndex].sort, sId ?? ''); + if (isRefresh) { + refreshController?.refreshCompleted(); + dataSource.clear(); + } + res?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(res?.list ?? []); + page += 1; + + isLoading = false; + update(); + } + + Future fetchTagDetail() async { + final res = await ACGService.fetchMediaTag(sId ?? ''); + if (res != null) { + update(); + } + return null; + } +} diff --git a/lib/hj_page/home/tag/cartoon_tag_page.dart b/lib/hj_page/home/tag/cartoon_tag_page.dart new file mode 100644 index 0000000..b8510ca --- /dev/null +++ b/lib/hj_page/home/tag/cartoon_tag_page.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +import '../../cartoon/acg_widget_item.dart'; +import '../home_cell_style/divider_tab_bar.dart'; +import 'cartoon_tag_logic.dart'; + +class CartoonTagPage extends StatefulWidget { + final String title; + final String? sId; + const CartoonTagPage({ + super.key, + this.title = '', + this.sId, + }); + + @override + State createState() => _CartoonTagPageState(); +} + +class _CartoonTagPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: CartoonTagLogic(widget.sId, false), + tag: uniqueTag, + builder: (controller) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.black, + title: Text(widget.title), + ), + body: Column( + children: [ + Container( + padding: EdgeInsets.only(left: 16), + child: DividerTabBar( + controller.sorts, + selectIndex: controller.sortIndex, + alignment: Alignment.center, + callback: (value) { + controller.sortIndex = value; + controller.fetchPageData(showLoading: true); + }, + ), + ), + Expanded( + child: pullYsRefresh( + onRefresh: (_) => controller.fetchPageData(), + onLoading: (_) => controller.fetchPageData(isRefresh: false), + onInit: (ctr) => controller.refreshController = ctr, + child: () { + if (controller.isLoading) return LoadingCenterWidget(); + if (controller.dataSource.isEmpty) return CErrorWidget(); + return GridView.builder( + padding: EdgeInsets.symmetric(horizontal: 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisSpacing: 6, + mainAxisSpacing: 12, + crossAxisCount: 3, + childAspectRatio: 111 / 190, + ), + itemCount: controller.dataSource.length, + itemBuilder: (BuildContext context, int index) { + return AcgItemWidget( + info: controller.dataSource[index]); + }, + ); + }(), + ), + ) + ], + ), + ); + }, + ); + } +} diff --git a/lib/hj_page/home/tag/video_tag_logic.dart b/lib/hj_page/home/tag/video_tag_logic.dart new file mode 100644 index 0000000..5586f63 --- /dev/null +++ b/lib/hj_page/home/tag/video_tag_logic.dart @@ -0,0 +1,56 @@ +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/tag_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +//视频标签页排序 tab(标题与接口 sortType 绑定) +const _sortTabs = [ + SortTab('最多收藏', 2), + SortTab('最新上架', 1), + SortTab('最新热评', 5), +]; + +class VideoTagLogic extends ListBaseLogic { + final TagsBean? tagModel; + final bool isShortStyle; + + VideoTagLogic(this.tagModel, {this.isShortStyle = false}); + + int sortIndex = 0; + List get sortTitles => _sortTabs.map((e) => e.name).toList(); + + @override + void onInit() { + super.onInit(); + loadData(); + } + + //isRefresh 下拉刷新/加载更多;showLoading 切换排序时先清空显示 loading + void loadData({bool isRefresh = true, bool showLoading = false}) { + if (showLoading) { + dataList = null; + update(); + } + fetchData(isRefresh: isRefresh, fetch: _fetch); + } + + Future<(List?, bool)> _fetch(int page) async { + final res = await TagService.fetchList( + tagModel?.id, + page: page, + size: 12, + sortType: _sortTabs[sortIndex].sort, + newsType: isShortStyle ? "SHORT" : "SP", + ); + return (res?.videos, res?.hasNext ?? false); + } + + Future fetchTagDetail() async { + final res = await TagService.fetchInfo(tagModel?.id ?? ''); + if (res != null) { + tagModel?.name = res.name; + update(); + } + return res; + } +} diff --git a/lib/hj_page/home/tag/video_tag_page.dart b/lib/hj_page/home/tag/video_tag_page.dart new file mode 100644 index 0000000..ed49f77 --- /dev/null +++ b/lib/hj_page/home/tag/video_tag_page.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +import '../../../routers/jump_router.dart'; +import '../../../tools_base/global_store/store.dart'; +import '../home_cell_style/video_simple_cell.dart'; +import '../home_cell_style/divider_tab_bar.dart'; +import 'video_tag_logic.dart'; + +class VideoTagPage extends StatefulWidget { + final TagsBean? model; + final bool isShortStyle; + final bool isHYStyle; // 黄油 + final bool isFromSearch; + + const VideoTagPage( + this.model, { + super.key, + this.isShortStyle = false, + this.isHYStyle = false, + this.isFromSearch = false, + }); + + @override + State createState() => _VideoTagPageState(); +} + +class _VideoTagPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: VideoTagLogic(widget.model, isShortStyle: widget.isShortStyle), + tag: uniqueTag, + builder: (logic) { + return Scaffold( + appBar: AppBar(title: Text(logic.tagModel?.name ?? '')), + body: Column( + children: [ + Container( + padding: EdgeInsets.only(left: 16), + child: DividerTabBar( + logic.sortTitles, + selectIndex: logic.sortIndex, + alignment: Alignment.center, + callback: (value) { + logic.sortIndex = value; + logic.loadData(showLoading: true); + }, + ), + ), + Expanded( + child: () { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.isEmptyData) return CErrorWidget(); + final list = logic.dataList!; + return pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onRefresh: (_) => logic.loadData(), + onLoading: (_) => logic.loadData(isRefresh: false), + child: GridView.builder( + padding: EdgeInsets.symmetric(horizontal: 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisSpacing: 6, + mainAxisSpacing: 12, + crossAxisCount: 2, + childAspectRatio: + widget.isShortStyle ? 168 / 266 : 168 / 154, + ), + itemCount: list.length, + itemBuilder: (BuildContext context, int index) { + VideoModel model = list[index]; + return VideoSimpleCell( + videoModel: model, + textLines: + (widget.isShortStyle || widget.isHYStyle) ? 1 : 2, + isFromHY: widget.isHYStyle, + isFromSearch: widget.isFromSearch, + onTap: () { + if (model.isDarkTag && !globalStore.isAWVIP) { + pushToWalletPage(vipId: Config.darkWebVipId); + } else { + pushToVideoPage( + videoModel: model, videoArr: list); + } + }, + ); + }, + ), + ); + }(), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/hj_page/home/widget/collect_status_wrapper.dart b/lib/hj_page/home/widget/collect_status_wrapper.dart new file mode 100644 index 0000000..6b9e59d --- /dev/null +++ b/lib/hj_page/home/widget/collect_status_wrapper.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/tools_base/event_bus/event_bus_util.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; + +import '../../../hj_model/acg/cartoon_more_list.dart'; +import '../../../hj_model/cartoon_media_info.dart'; + +typedef WidgetBuilder = Widget Function(); + +/// 将点赞/收藏/关注事件同步到 [VideoModel](列表与详情可共用同一引用) +void applyVideoModelCollectStatus(VideoModel? model, CollectStatusModel event) { + if (model == null || event.id?.isNotEmpty != true || model.id != event.id) { + return; + } + if (event.isCollected != null) { + model.vidStatus?.hasCollected = event.isCollected; + } + if (event.isLiked != null) { + model.vidStatus?.hasLiked = event.isLiked; + if (event.likeCountDelta != null) { + model.likeCount = max(0, (model.likeCount ?? 0) + event.likeCountDelta!); + } + } + if (event.isFollowed != null && event.uid == model.publisher?.uid) { + model.publisher?.hasFollowed = event.isFollowed; + } +} + +class CollectStatusWrapper extends StatefulWidget { + final WidgetBuilder builder; + final AllMediaInfo? allMediaInfo; + final CartoonMediaInfo? mediaInfo; + final VideoModel? videoModel; + final TagsBean? tagModel; + const CollectStatusWrapper({ + required this.builder, + super.key, + this.mediaInfo, + this.allMediaInfo, + this.videoModel, + this.tagModel, + }); + + @override + State createState() { + return _CollectStatusWrapperState(); + } +} + +class _CollectStatusWrapperState extends State { + late StreamSubscription subscription; + @override + void initState() { + super.initState(); + subscription = eventBus.on(_listenCallback); + } + + void _listenCallback(CollectStatusModel model) { + String? obId = widget.allMediaInfo?.id ?? + widget.mediaInfo?.id ?? + widget.videoModel?.id ?? + widget.tagModel?.id; + //收藏和点赞逻辑 + if (obId == model.id && obId?.isNotEmpty == true) { + if (model.isCollected != null) { + widget.allMediaInfo?.mediaStatus?.hasCollected = model.isCollected; + widget.mediaInfo?.mediaStatus?.hasCollected = model.isCollected; + widget.videoModel?.vidStatus?.hasCollected = model.isCollected; + widget.tagModel?.hasCollected = model.isCollected; + setState(() {}); + } else if (model.isLiked != null) { + widget.allMediaInfo?.mediaStatus?.hasLiked = model.isLiked; + widget.mediaInfo?.mediaStatus?.hasLiked = model.isLiked; + widget.videoModel?.vidStatus?.hasLiked = model.isLiked; + setState(() {}); + } else {} + } + // 用户关注逻辑 + if (model.isFollowed != null && + model.uid == widget.videoModel?.publisher?.uid) { + widget.videoModel?.publisher?.hasFollowed = model.isFollowed; + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return widget.builder(); + } + + @override + void dispose() { + subscription.cancel(); + eventBus.off(subscription); + super.dispose(); + } +} diff --git a/lib/hj_page/home/widget/home_sort_menu_view.dart b/lib/hj_page/home/widget/home_sort_menu_view.dart new file mode 100644 index 0000000..0642c09 --- /dev/null +++ b/lib/hj_page/home/widget/home_sort_menu_view.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +class HomeSortMenuView extends StatefulWidget { + final List titleArr; + final int selectIndex; + final Function(int)? callback; + final String gapChar; + + const HomeSortMenuView(this.titleArr, {super.key, this.selectIndex = 0, this.callback, this.gapChar = '/'}); + + @override + State createState() { + return _HomeSortMenuViewState(); + } +} + +class _HomeSortMenuViewState extends State { + int selectIndex = 0; + + @override + void initState() { + selectIndex = widget.selectIndex; + super.initState(); + } + + @override + void didUpdateWidget(covariant HomeSortMenuView oldWidget) { + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + List widgetArr = []; + for (int i = 0; i < widget.titleArr.length; i++) { + widgetArr.add(_buildMenuButton(widget.titleArr[i], i == selectIndex, i)); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: widgetArr, + ); + } + + Widget _buildMenuButton(String text, bool isSelected, int index) { + bool isLast = index == (widget.titleArr.length - 1); + return InkWell(enableFeedback: false, + onTap: () { + selectIndex = index; + setState(() {}); + widget.callback?.call(index); + }, + child: Container( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + text, + style: TextStyle( + fontSize: 14, + color: isSelected ? Color(0xE5FFFFFF) : const Color(0x73FFFFFF), + ), + ), + const SizedBox(width: 12), + if (isLast) + const SizedBox(width: 12) + else + Text( + widget.gapChar, + style: TextStyle( + fontSize: 14, + color: Color(0x73FFFFFF), + ), + ), + const SizedBox(width: 12), + ], + ), + ), + ); + } + + @override + void dispose() { + super.dispose(); + } +} diff --git a/lib/hj_page/home/widget/home_txt_marquee.dart b/lib/hj_page/home/widget/home_txt_marquee.dart new file mode 100644 index 0000000..1efcf2a --- /dev/null +++ b/lib/hj_page/home/widget/home_txt_marquee.dart @@ -0,0 +1,564 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_model/splash/domain_source_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../hj_utils/api_service/vid_service.dart'; +import '../../../routers/jump_router.dart'; + +class HomeTxtMarQuee extends StatefulWidget { + final double stepOffset; + //首页读取type=1,暗网读取type=2,发现读取type=4,社区读取type=3,我的界面读取type=0 + final int typeValue; + final EdgeInsetsGeometry? margin; + final bool showClose; + final double? fontSize; + final double? iconSize; + final double? height; + final EdgeInsets? padding; + final double borderRadius; + + HomeTxtMarQuee({ + this.stepOffset = 1, + this.typeValue = 0, + this.margin, + this.showClose = true, + this.fontSize, + this.iconSize, + this.height, + this.padding, + this.borderRadius = 0, + super.key, + }); + + @override + State createState() { + return HomeTxtMarQueeState(); + } +} + +class HomeTxtMarQueeState extends State + with SingleTickerProviderStateMixin { + List? marquees; + + bool isShow = true; + + // 关闭收起动画(value 1→0:高度收起 + 淡出) + late final AnimationController closeAniCtr = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + value: 1, + ); + + @override + void initState() { + super.initState(); + marquees = Config.marquees; + if (marquees == null) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _loadData(); + }); + } + } + + _loadData() async { + await VidService.fetchAnnounce(widget.typeValue); + marquees = Config.marquees; + if (mounted) setState(() {}); + } + + // 点关闭先播收起动画,结束后再移除 + void _close() { + closeAniCtr.reverse().then((_) { + if (mounted) setState(() => isShow = false); + }); + } + + @override + void dispose() { + closeAniCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (marquees == null || marquees!.isEmpty || !isShow) return SizedBox(); + return FadeTransition( + opacity: closeAniCtr, + child: _marqueeBar(), + ); + } + + Widget _marqueeBar() { + return Container( + height: widget.height ?? 36, + alignment: Alignment.centerLeft, + margin: widget.margin, + padding: widget.padding ?? + EdgeInsets.only(left: 14, right: 14, top: 8, bottom: 8), + decoration: BoxDecoration( + color: Color(0xCC000000), + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + child: Row( + children: [ + Image.asset( + "icon_broadcast.png".commonImgPath, + width: widget.iconSize ?? 20, + ), + 6.sizeBoxW, + Expanded( + child: InfiniteMarquee( + stepOffset: widget.stepOffset, + itemBuilder: (BuildContext context, int index) { + final idx = index % marquees!.length; + final model = marquees![idx]; + return InkWell( + enableFeedback: false, + onTap: () => pushToPageByLink(model.url), + child: Text( + model.content ?? '', + style: TextStyle( + color: Color(0xff989898), + fontSize: widget.fontSize ?? 12, + height: 1.4), + ), + ); + }, + ), + ), + if (widget.showClose) ...[ + 6.sizeBoxW, + GestureDetector( + onTap: _close, + child: Icon(Icons.close, size: 18, color: Color(0xffDCDCDC)), + ) + ], + ], + ), + ); + } +} + +// 无限滚动list +class InfiniteMarquee extends StatefulWidget { + /// 每次移动步长,默认0.5,越大越快 + final double stepOffset; + + /// 自定义内容 + final IndexedWidgetBuilder itemBuilder; + + InfiniteMarquee({ + super.key, + this.stepOffset = 0.5, + required this.itemBuilder, + }); + + @override + State createState() => _InfiniteMarqueeState(); +} + +class _InfiniteMarqueeState extends State { + // 执行动画的controller + InfiniteScrollController? _controller; + + // 定时器timer + Timer? _timer; + + // 定时器时间 + Duration duration = Duration(milliseconds: 30); + + // 手势打断定时器 + bool timerStop = false; + + // 执行位移开始的偏移量 + double _offset = 0.0; + + @override + void initState() { + super.initState(); + _controller = InfiniteScrollController(initialScrollOffset: _offset); + _startScrollTimer(); + } + + /// 开启定时器 + _startScrollTimer() { + _timer = Timer.periodic(duration, (timer) { + _autoScroll(); + }); + } + + /// 自动滚动 + _autoScroll() { + double newOffset = (_controller?.offset ?? 0) + widget.stepOffset; + if (timerStop == false) { + _offset = newOffset; + _controller?.jumpTo(_offset); + } + } + + @override + void dispose() { + _timer?.cancel(); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + /// 监听滚动 + return InfiniteListView.separated( + scrollDirection: Axis.horizontal, + controller: _controller, + itemBuilder: widget.itemBuilder, + physics: NeverScrollableScrollPhysics(), + separatorBuilder: (BuildContext context, int index) => + SizedBox(width: Get.width - 100), + anchor: 0, + ); + } +} + +class InfiniteListView extends StatefulWidget { + /// See [ListView.builder] + const InfiniteListView.builder({ + super.key, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.controller, + this.physics, + this.padding, + this.itemExtent, + required this.itemBuilder, + this.itemCount, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + this.cacheExtent, + this.anchor = 0.0, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + }) : separatorBuilder = null; + + /// See [ListView.separated] + const InfiniteListView.separated({ + super.key, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.controller, + this.physics, + this.padding, + required this.itemBuilder, + required this.separatorBuilder, + this.itemCount, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + this.cacheExtent, + this.anchor = 0.0, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + }) : itemExtent = null; + + /// See: [ScrollView.scrollDirection] + final Axis scrollDirection; + + /// See: [ScrollView.reverse] + final bool reverse; + + /// See: [ScrollView.controller] + final InfiniteScrollController? controller; + + /// See: [ScrollView.physics] + final ScrollPhysics? physics; + + /// See: [BoxScrollView.padding] + final EdgeInsets? padding; + + /// See: [ListView.builder] + final IndexedWidgetBuilder itemBuilder; + + /// See: [ListView.separated] + final IndexedWidgetBuilder? separatorBuilder; + + /// See: [SliverChildBuilderDelegate.childCount] + final int? itemCount; + + /// See: [ListView.itemExtent] + final double? itemExtent; + + /// See: [ScrollView.cacheExtent] + final double? cacheExtent; + + /// See: [ScrollView.anchor] + final double anchor; + + /// See: [SliverChildBuilderDelegate.addAutomaticKeepAlives] + final bool addAutomaticKeepAlives; + + /// See: [SliverChildBuilderDelegate.addRepaintBoundaries] + final bool addRepaintBoundaries; + + /// See: [SliverChildBuilderDelegate.addSemanticIndexes] + final bool addSemanticIndexes; + + /// See: [ScrollView.dragStartBehavior] + final DragStartBehavior dragStartBehavior; + + /// See: [ScrollView.keyboardDismissBehavior] + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// See: [ScrollView.restorationId] + final String? restorationId; + + /// See: [ScrollView.clipBehavior] + final Clip clipBehavior; + + @override + _InfiniteListViewState createState() => _InfiniteListViewState(); +} + +class _InfiniteListViewState extends State { + InfiniteScrollController? _controller; + + InfiniteScrollController get _effectiveController => + widget.controller ?? _controller!; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _controller = InfiniteScrollController(); + } + } + + @override + void didUpdateWidget(InfiniteListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == null && oldWidget.controller != null) { + _controller = InfiniteScrollController(); + } else if (widget.controller != null && oldWidget.controller == null) { + _controller!.dispose(); + _controller = null; + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final List slivers = _buildSlivers(context, negative: false); + final List negativeSlivers = _buildSlivers(context, negative: true); + final AxisDirection axisDirection = _getDirection(context); + final scrollPhysics = + widget.physics ?? const AlwaysScrollableScrollPhysics(); + return Scrollable( + axisDirection: axisDirection, + controller: _effectiveController, + physics: scrollPhysics, + viewportBuilder: (BuildContext context, ViewportOffset offset) { + return Builder(builder: (BuildContext context) { + /// Build negative [ScrollPosition] for the negative scrolling [Viewport]. + final state = Scrollable.of(context); + final negativeOffset = _InfiniteScrollPosition( + physics: scrollPhysics, + context: state, + initialPixels: -offset.pixels, + keepScrollOffset: _effectiveController.keepScrollOffset, + negativeScroll: true, + ); + + /// Keep the negative scrolling [Viewport] positioned to the [ScrollPosition]. + offset.addListener(() { + negativeOffset._forceNegativePixels(offset.pixels); + }); + + /// Stack the two [Viewport]s on top of each other so they move in sync. + return Stack( + children: [ + Viewport( + axisDirection: flipAxisDirection(axisDirection), + anchor: 1.0 - widget.anchor, + offset: negativeOffset, + slivers: negativeSlivers, + cacheExtent: widget.cacheExtent, + ), + Viewport( + axisDirection: axisDirection, + anchor: widget.anchor, + offset: offset, + slivers: slivers, + cacheExtent: widget.cacheExtent, + ), + ], + ); + }); + }, + ); + } + + AxisDirection _getDirection(BuildContext context) { + return getAxisDirectionFromAxisReverseAndDirectionality( + context, widget.scrollDirection, widget.reverse); + } + + List _buildSlivers(BuildContext context, {bool negative = false}) { + final itemExtent = widget.itemExtent; + final padding = widget.padding ?? EdgeInsets.zero; + return [ + SliverPadding( + padding: negative + ? padding - EdgeInsets.only(bottom: padding.bottom) + : padding - EdgeInsets.only(top: padding.top), + sliver: (itemExtent != null) + ? SliverFixedExtentList( + delegate: negative + ? negativeChildrenDelegate + : positiveChildrenDelegate, + itemExtent: itemExtent, + ) + : SliverList( + delegate: negative + ? negativeChildrenDelegate + : positiveChildrenDelegate, + ), + ) + ]; + } + + SliverChildDelegate get negativeChildrenDelegate { + return SliverChildBuilderDelegate( + (BuildContext context, int index) { + final separatorBuilder = widget.separatorBuilder; + if (separatorBuilder != null) { + final itemIndex = (-1 - index) ~/ 2; + return index.isOdd + ? widget.itemBuilder(context, itemIndex) + : separatorBuilder(context, itemIndex); + } else { + return widget.itemBuilder(context, -1 - index); + } + }, + childCount: widget.itemCount, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + ); + } + + SliverChildDelegate get positiveChildrenDelegate { + final separatorBuilder = widget.separatorBuilder; + final itemCount = widget.itemCount; + return SliverChildBuilderDelegate( + (separatorBuilder != null) + ? (BuildContext context, int index) { + final itemIndex = index ~/ 2; + return index.isEven + ? widget.itemBuilder(context, itemIndex) + : separatorBuilder(context, itemIndex); + } + : widget.itemBuilder, + childCount: separatorBuilder == null + ? itemCount + : (itemCount != null ? max(0, itemCount * 2 - 1) : null), + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + .add(EnumProperty('scrollDirection', widget.scrollDirection)); + properties.add(FlagProperty('reverse', + value: widget.reverse, ifTrue: 'reversed', showName: true)); + properties.add(DiagnosticsProperty( + 'controller', widget.controller, + showName: false, defaultValue: null)); + properties.add(DiagnosticsProperty('physics', widget.physics, + showName: false, defaultValue: null)); + properties.add(DiagnosticsProperty( + 'padding', widget.padding, + defaultValue: null)); + properties.add( + DoubleProperty('itemExtent', widget.itemExtent, defaultValue: null)); + properties.add( + DoubleProperty('cacheExtent', widget.cacheExtent, defaultValue: null)); + } +} + +/// Same as a [ScrollController] except it provides [ScrollPosition] objects with infinite bounds. +class InfiniteScrollController extends ScrollController { + /// Creates a new [InfiniteScrollController] + InfiniteScrollController({ + super.initialScrollOffset, + super.keepScrollOffset, + super.debugLabel, + }); + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, + ScrollContext context, ScrollPosition? oldPosition) { + return _InfiniteScrollPosition( + physics: physics, + context: context, + initialPixels: initialScrollOffset, + keepScrollOffset: keepScrollOffset, + oldPosition: oldPosition, + debugLabel: debugLabel, + ); + } +} + +class _InfiniteScrollPosition extends ScrollPositionWithSingleContext { + _InfiniteScrollPosition({ + required super.physics, + required super.context, + super.initialPixels, + super.keepScrollOffset, + super.oldPosition, + super.debugLabel, + this.negativeScroll = false, + }); + + final bool negativeScroll; + + void _forceNegativePixels(double value) { + super.forcePixels(-value); + } + + @override + void saveScrollOffset() { + if (!negativeScroll) { + super.saveScrollOffset(); + } + } + + @override + void restoreScrollOffset() { + if (!negativeScroll) { + super.restoreScrollOffset(); + } + } + + @override + double get minScrollExtent => double.negativeInfinity; + + @override + double get maxScrollExtent => double.infinity; +} diff --git a/lib/hj_page/home/widget/publist_entry_alert.dart b/lib/hj_page/home/widget/publist_entry_alert.dart new file mode 100644 index 0000000..c1654e2 --- /dev/null +++ b/lib/hj_page/home/widget/publist_entry_alert.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; // sizeBoxH/sizeBoxW 扩展 +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; + +import '../../community/publish_page/publish_page.dart'; + +enum PublishEntry { + community(40, 40); + + final double width; + final double height; + + const PublishEntry(this.width, this.height); + + String realPath() => 'publish.png'.homePath; +} + +class PublishButton extends StatelessWidget { + final PublishEntry entry; + + const PublishButton({super.key, this.entry = PublishEntry.community}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Get.bottomSheet(CommunityPublishBottomSheet(), + isScrollControlled: true, isDismissible: true), + child: Image.asset( + entry.realPath(), + width: entry.width.w, + height: entry.height.h, + ), + ); + } +} + +class CommunityPublishBottomSheet extends StatelessWidget { + const CommunityPublishBottomSheet({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + // 底部加系统安全区,避免手势导航条遮住按钮(如 vivo iQOO 13) + padding: EdgeInsets.only( + left: 18.w, + right: 18.w, + top: 18, + bottom: 18 + Get.mediaQuery.padding.bottom), + decoration: BoxDecoration( + color: AppColors.primaryColor, + borderRadius: BorderRadius.vertical(top: Radius.circular(13))), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SheetHandleBar(), + 18.sizeBoxH, + Text( + '选择发布类型', + style: TextStyle( + color: Colors.white, + fontSize: 20.sp, + fontWeight: FontWeight.w900), + ), + 40.h.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _publishItem('publish_img.webp', '图片', PublishType.homeImg), + 40.sizeBoxW, + _publishItem('publish_video.webp', '视频', PublishType.homeVideo), + 40.sizeBoxW, + _publishItem( + 'publish_img_text.webp', '图文', PublishType.homeImgText), + ], + ), + ], + ), + ); + } + + Widget _publishItem(String img, String label, PublishType type) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + Get.back(); + Get.to(() => PublishPage(type: type)); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset(img.communityPath, width: 45.w, height: 45.w), + 4.sizeBoxH, + Text(label, + style: + TextStyle(color: const Color(0xffa3a2a2), fontSize: 18.sp)), + ], + ), + ); + } +} diff --git a/lib/hj_page/home/widget/quick_search_view.dart b/lib/hj_page/home/widget/quick_search_view.dart new file mode 100644 index 0000000..fa59f0f --- /dev/null +++ b/lib/hj_page/home/widget/quick_search_view.dart @@ -0,0 +1,79 @@ +import 'dart:math'; + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/comment/comment_list_res.dart'; +import 'package:hgdj/routers/jump_router.dart'; + +class QuickSearchView extends StatefulWidget { + final List dataSource; + + const QuickSearchView(this.dataSource, {super.key}); + + @override + State createState() { + return _QuickSearchViewState(); + } +} + +class _QuickSearchViewState extends State { + CommentLink? model; + final realQuickSearchs = []; + + @override + void initState() { + super.initState(); + realQuickSearchs.addAll(widget.dataSource.where((e) => e.type == 2)); + loadData(); + } + + void loadData() { + if (model == null && realQuickSearchs.isNotEmpty) { + if (realQuickSearchs.length > 1) { + final index = Random().nextInt(realQuickSearchs.length); + model = realQuickSearchs[index]; + } else { + model = realQuickSearchs.first; + } + } + } + + @override + void didUpdateWidget(covariant QuickSearchView oldWidget) { + loadData(); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + if (model == null) return SizedBox(); + return GestureDetector( + onTap: () { + pushToPageByLink(model?.link ?? '', arguments: {'id': model?.id}); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: EasyRichText( + '${model?.title ?? ''}', + defaultStyle: TextStyle(color: Colors.white, fontSize: 14), + patternList: [ + EasyRichTextPattern( + targetString: model?.searchKeyword ?? '', + style: TextStyle( + color: Color(0xffDB361F), fontSize: 14, height: 1)) + ], + ), + ), + Image.asset( + 'hot_search.png'.communityPath, + width: 9, + height: 9, + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/home/widget/support_up_alert.dart b/lib/hj_page/home/widget/support_up_alert.dart new file mode 100644 index 0000000..a621699 --- /dev/null +++ b/lib/hj_page/home/widget/support_up_alert.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:provider/provider.dart'; + +import '../../mine/mine_vip/mine_charge_coin_page.dart'; +import '../../mine/widgets/gradient_text.dart'; + +class SupportUPAlert extends StatefulWidget { + final bool isPublish; + final int? selectCoin; + const SupportUPAlert({super.key, this.isPublish = false, this.selectCoin}); + + @override + State createState() => _SupportUPAlertState(); +} + +class _SupportUPAlertState extends State { + Rx selectValue = Rx(0); + final textEidtCtr = TextEditingController(); + final focusN = FocusNode(); + final coins = ['10', '20', '30', '40', '50']; + var enable = false.obs; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + globalStore.refreshWallet().then((value) => checkEnable()); + }); + selectValue.value = widget.selectCoin; + final contain = coins.contains(widget.selectCoin.toString()); + if (!contain) + textEidtCtr.text = + widget.selectCoin == null ? '' : widget.selectCoin.toString(); + checkEnable(); + } + + checkEnable() { + if (widget.isPublish) { + enable.value = true; + } else { + final gold = globalStore.wallet?.amount ?? 0; + if (gold == 0) + enable.value = false; + else { + final selectCoin = selectValue.value ?? 0; + if (selectCoin == 0) + enable.value = true; + else { + enable.value = gold >= selectCoin; + } + } + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Color(0xff0F0F0F), + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: _buildContent(), + ); + } + + _buildContent() { + return Container( + height: 420, + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Container( + width: 60, + margin: EdgeInsets.symmetric(vertical: 18), + height: 6, + decoration: BoxDecoration( + color: Color(0x33FFFFFF), + borderRadius: BorderRadius.circular(4), + ), + ), + Text( + widget.isPublish ? '设置价格' : '为喜欢的UP主加油', + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 20, + fontWeight: FontWeight.w500), + ), + 18.sizeBoxH, + Container( + padding: EdgeInsets.symmetric(horizontal: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LayoutBuilder(builder: (_, cons) { + final width_ = (cons.maxWidth - 48) / 4; + return Wrap( + spacing: 12, + runSpacing: 12, + children: + ['10', '20', '30', '40', '50', '60', '70', '80'].map( + (e) { + final gold = int.tryParse(e); + return Obx(() { + final select = gold == (selectValue.value ?? 0); + return GestureDetector( + onTap: gold == null + ? null + : () { + selectValue.value = gold; + focusN.unfocus(); + textEidtCtr.clear(); + checkEnable(); + }, + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: (gold != null && select) + ? AppColors.actionRed + : Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(10), + border: (gold != null && select) + ? Border.all(color: AppColors.actionRed) + : Border.all(color: Color(0x1AFFFFFF))), + width: width_, + height: width_ * 1.1, + child: () { + if (gold != null) + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'community_coin.webp'.communityPath, + width: 20, + height: 20, + ), + 12.sizeBoxH, + GradientText( + '$gold金币', + style: TextStyle( + fontSize: 12, + ), + gradient: LinearGradient( + colors: select + ? [ + Color(0xffFFE8BE), + Color(0xffE6B764) + ] + : [ + Colors.white + .withValues(alpha: .55), + Colors.white + .withValues(alpha: .55), + ], + ), + ) + ], + ); + }(), + ), + ); + }); + }, + ).toList(), + ); + }), + 20.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("自定义", style: TextStyle(color: Color(0x8CFFFFFF))), + Container( + margin: EdgeInsets.symmetric(horizontal: 5, vertical: 0), + width: 60, + decoration: BoxDecoration( + color: Color(0x33FFFFFF), + borderRadius: BorderRadius.circular(6)), + child: TextField( + controller: textEidtCtr, + focusNode: focusN, + textAlign: TextAlign.center, + onChanged: (value) { + selectValue.value = int.tryParse(value); + checkEnable(); + }, + keyboardType: TextInputType.number, + maxLength: 4, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9]')) + ], + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500), + decoration: InputDecoration( + border: InputBorder.none, + counterText: '', + hintText: '100', + isDense: true, + contentPadding: EdgeInsets.zero, + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: .6), + fontSize: 12), + ), + ), + ), + Text("金币", style: TextStyle(color: Color(0x8CFFFFFF))), + ], + ), + 20.sizeBoxH, + Consumer(builder: (_, store, __) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!widget.isPublish) ...[ + Text( + '钱包余额:${store.wallet?.amount ?? 0}', + style: TextStyle( + color: Colors.white.withValues(alpha: .6), + fontSize: 12, + height: 14 / 12), + ), + 16.sizeBoxH, + ], + Obx(() { + if (enable.value) + return Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => + Get.back(result: selectValue.value), + child: Container( + height: 54, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed, + ), + child: Text( + widget.isPublish ? '确定设置' : '立即打赏', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500), + ), + ), + ), + ), + ], + ); + return GestureDetector( + onTap: () => Get.to(MineChargeCoinPage()), + child: Container( + height: 38, + margin: EdgeInsets.symmetric(horizontal: 20), + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3)), + child: Text( + '余额不足 前往充值', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + ), + ); + }), + ], + ); + }) + ], + ), + ) + ], + ), + ); + } +} diff --git a/lib/hj_page/home/widget/user_avatar.dart b/lib/hj_page/home/widget/user_avatar.dart new file mode 100644 index 0000000..d09a724 --- /dev/null +++ b/lib/hj_page/home/widget/user_avatar.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/user_center_page/user_center_page.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +class UserAvatar extends StatelessWidget { + final double size; + final bool showVip; + final Publisher? model; + final bool isCircle; + final double? bigVsize; + final bool showBorder; + final GestureTapCallback? onTap; + const UserAvatar({ + super.key, + this.size = 52, + this.showVip = false, + this.model, + this.isCircle = true, + this.bigVsize, + this.showBorder = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + border: showBorder + ? Border.all(color: Color(0xffDB361F), width: 2) + : null, + borderRadius: BorderRadius.circular(100)), + child: Stack( + alignment: Alignment.center, + children: [ + GestureDetector( + onTap: onTap ?? + () { + Get.to(() => UserCenterPage(uid: model?.uid ?? 0), + preventDuplicates: false); + }, + child: NetworkImageLoader( + imageUrl: model?.portrait ?? '', + width: size - (showBorder ? 4 : 0), + height: size - (showBorder ? 4 : 0), + borderRadius: isCircle ? size / 2 : 0, + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/home/widget/user_name_view.dart b/lib/hj_page/home/widget/user_name_view.dart new file mode 100644 index 0000000..cddb1a9 --- /dev/null +++ b/lib/hj_page/home/widget/user_name_view.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class UserNameView extends StatelessWidget { + final String? name; + final bool? isVip; + final bool? isOfficial; + final double fontSize; + final FontWeight fontWeight; + final Color? nameColor; + + const UserNameView({ + super.key, + this.name, + this.isVip, + this.isOfficial, + this.fontSize = 14, + this.fontWeight = FontWeight.w500, + this.nameColor, + }); + + @override + Widget build(BuildContext context) { + if (name?.isEmpty ?? true) return SizedBox.shrink(); + + return Text( + name ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: nameColor ?? Color(0xff9A9A9A), + fontSize: fontSize, + fontWeight: fontWeight, + ), + ); + } +} diff --git a/lib/hj_page/live/live_detail_logic.dart b/lib/hj_page/live/live_detail_logic.dart new file mode 100644 index 0000000..9885d13 --- /dev/null +++ b/lib/hj_page/live/live_detail_logic.dart @@ -0,0 +1,210 @@ +import 'package:hgdj/hj_model/list_base_model.dart'; +import 'dart:async'; + +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/live/live_service.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/tools_base/event_bus/event_bus_util.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; +import 'package:video_player/video_player.dart'; +import 'package:hgdj/hj_utils/video_view_type.dart'; + +import '../../alert/video/share_media_dialog.dart'; +import '../mine/mine_vip/mine_charge_vip_page.dart'; +import 'live_model.dart'; + +//直播详情 +class LiveDetailLogic extends GetxController { + LiveAnchor? anchor; //直播 + int currentPage = 1; + bool loading = true; //是否在加载推荐列表 + bool loadingUserInfo = true; //加载用户会员信息等 + List recommendList = []; // + + RefreshController? controller; //由 pullYsRefresh 的 onInit 注入,组件负责释放 + VideoPlayerController? playerController; + + bool initedVideoController = false; //视频控制器是否加载 + RxBool isLoading = true.obs; //是否在切换视频 + + //视频真实比例, 竖屏的比例,采用最大高度400,横屏的比例,保持原有比例 + //是否是竖向视频 + double? aspectRatio = 16 / 9; //0.75 + bool get isVerticalVideo => (aspectRatio ?? 1) > 1; + + bool isDispose = false; + + //判断播放权限 + bool get canWatchLive => + DateTimeUtil.isExpireDate(globalStore.meInfo?.broadcastExpire); + + LiveDetailLogic({this.anchor}); + + StreamSubscription? _pauseVideoSub; + + @override + void onInit() { + super.onInit(); + // 耳机/蓝牙断开、来电中断时暂停,防止外放泄露 + _pauseVideoSub = + eventBus.on((_) => playerController?.pause()); + } + + @override + void onReady() async { + super.onReady(); + Future.wait([globalStore.updateUserInfo(), loadData(initVideo: false)]) + .then( + (value) { + update(); //接口调用成功刷新一次直播状态 + loadingUserInfo = false; + initPlayer(); + }, + ); + loadrecommendAnchors(); + } + + @override + void onClose() async { + isDispose = true; + _pauseVideoSub?.cancel(); + playerDispose(); + super.onClose(); + } + + playerDispose() { + if (playerController != null) { + initedVideoController = false; + playerController?.removeListener(_listenerCallback); + playerController?.pause(); + playerController?.dispose(); + playerController = null; + } + } + + initPlayer({bool isRetry = false}) async { + if (anchor?.url != null && anchor?.url?.isNotEmpty == true) { + playerController = PlayerFactory.network(anchor?.url); + + try { + await playerController?.initialize(); + if (isRetry) + confirmPlatformView(); // 仅重试成功(同视频 textureView 挂、platformView 放出)才落本地 + playerController?.addListener(_listenerCallback); + if (isDispose) { + playerController?.pause(); + playerController?.dispose(); + return; + } + initedVideoController = true; + aspectRatio = playerController?.value.aspectRatio ?? 16 / 9; + update(); + if (canWatchLive) playerController?.play(); + } catch (e) { + playerController?.dispose(); + // 首次芯片解码/渲染报错:仅内存切 platformView 重试,成功后才落本地 + if (isDecoderError(e) && switchToPlatformView()) { + initPlayer(isRetry: true); + return; + } + //播放器初始化失败,强制设置为离线 + anchor?.isOnline = false; + update(['player']); + } + } else { + anchor?.isOnline = false; + update(); + } + } + + //添加监听 + _listenerCallback() { + isLoading.value = playerController?.value.isBuffering == true; + } + + //获取直播详情 + Future loadData({bool initVideo = true}) async { + LiveAnchor? result = + await LiveService.getLiveAnchorDetail(id: anchor?.id ?? ''); + controller?.refreshCompleted(); + if (result != null) { + //不需要赋值图片url,因为图片随时在变,防止图片闪动 + anchor?.isOnline = result.isOnline; + anchor?.url = result.url; + anchor?.viewCount = result.viewCount; + if (initVideo) initPlayer(); + } else { + anchor?.isOnline = false; + } + } + + //直播推荐列表 + loadrecommendAnchors({int pageNum = 1}) async { + ListBaseModel? liveMainModels = + await LiveService.getLiveAnchorRecom( + country: anchor?.country ?? '', + pageNumber: pageNum, + pageSize: 20, + ); + loading = false; + if (liveMainModels != null) { + currentPage = pageNum; + if (currentPage == 1) recommendList.clear(); + if (liveMainModels.list != null && + liveMainModels.list?.isNotEmpty == true) { + recommendList.addAll(liveMainModels.list ?? []); + } + } + controller?.refreshCompleted(); + update(['recommend']); + liveMainModels?.hasNext == true + ? controller?.loadComplete() + : controller?.loadNoData(); + } + + loadMoreData() => loadrecommendAnchors(pageNum: currentPage + 1); + + //切换直播 + onChangeLiveAction(LiveAnchor change) { + if (change.id == anchor?.id) { + showToast('当前在此直播间哦~'); + return; + } + anchor = change; + if (playerController != null) { + final old = playerController; + old?.pause(); + isLoading.value = true; + playerController?.removeListener(_listenerCallback); + } + initedVideoController = false; + if (anchor?.vidWidth != 0 && anchor?.vidHeight != 0) { + aspectRatio = (anchor?.vidWidth ?? 1) / (anchor?.vidHeight ?? 1); + } else { + aspectRatio = 16 / 9; + } + update(); + loadData(); + } + + //跳转充值 + onChargeAction() async { + Get.to(MineChargeVipPage(), preventDuplicates: false); + await globalStore.updateUserInfo(); + + //true表示支付成功 + if (canWatchLive) update(); + } + + //分享 + onShareAction() { + VideoModel videoModel = VideoModel()..cover = anchor?.coverImg; + + Get.dialog(ShareMediaDialog(videoModel: videoModel, needDecrypt: false), + useSafeArea: false); + } +} diff --git a/lib/hj_page/live/live_detail_page.dart b/lib/hj_page/live/live_detail_page.dart new file mode 100644 index 0000000..c3fcb5b --- /dev/null +++ b/lib/hj_page/live/live_detail_page.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/live/live_item_widget.dart'; +import 'package:hgdj/hj_page/live/live_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/hj_utils/sliver_delegate.dart'; +import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:video_player/video_player.dart'; + +import '../video/video_full_page.dart'; +import 'live_detail_logic.dart'; + +//直播详情页 +class LiveDetailPage extends StatefulWidget { + final LiveAnchor? anchor; + const LiveDetailPage({super.key, this.anchor}); + + @override + State createState() => _LiveDetailPageState(); +} + +class _LiveDetailPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: GetBuilder( + init: LiveDetailLogic(anchor: widget.anchor), + builder: (logic) => Stack( + children: [ + _buildBody(logic), + Positioned(left: 10, top: 10, child: _buildBack()), + ], + ), + ), + ), + ); + } + + //页面框架 + Widget _buildBody(LiveDetailLogic logic) { + return pullYsRefresh( + enablePullDown: false, + onInit: (c) => logic.controller = c, + onLoading: (c) => logic.loadMoreData(), + child: CustomScrollView( + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: MySliverDelegate( + forceRefresh: true, + maxHeight: Get.width / (logic.aspectRatio ?? 1), + minHeight: logic.isVerticalVideo + ? Get.width / (logic.aspectRatio ?? 1) + : Get.height / 3 - 60, + child: ClipRRect( + //部分机型会超出比例,裁剪掉多余部分 + child: Container(color: Colors.black, child: _buildPlayer()), + ), + ), + ), + SliverToBoxAdapter(child: _buildAnchorInfo(logic)), + SliverToBoxAdapter(child: _buildAds()), + 10.sliverSizeBoxH, + _buildDivider(), + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.fromLTRB(16, 18, 16, 12), + child: const Text( + '热门直播', + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w500), + ), + ), + ), + _buildRecommend(), + 18.sliverSizeBoxH, + _buildDivider(), + ], + ), + ); + } + + //视频播放区 + Widget _buildPlayer() { + return GetBuilder( + id: 'player', + builder: (logic) => Stack( + alignment: Alignment.center, + children: [ + if (logic.loadingUserInfo) ...[ + //1.加载用户直播权益信息 + AspectRatio( + aspectRatio: logic.aspectRatio ?? 1, child: _buildCover(logic)), + const LoadingCenterWidget(), + ] else if (!logic.canWatchLive) ...[ + //2.无权限 + _buildNoPermission(logic), + ] else if (logic.anchor?.isOnline != true) ...[ + //3.主播离线 + _buildOffline(logic), + ] else ...[ + //4.主播在线 + Hero( + tag: 'player', + child: AspectRatio( + aspectRatio: logic.aspectRatio ?? 1, + child: logic.initedVideoController + ? VideoPlayer(logic.playerController!) + : _buildCover(logic), + ), + ), + if (logic.initedVideoController) + Positioned( + right: 12, + bottom: 6, + child: GestureDetector( + onTap: () => Get.to(() => VideoFullPage( + playCtr: logic.playerController!, showMenu: false)), + child: Image.asset("full_icon.png".videoPath, + width: 26, height: 26), + ), + ), + Obx(() => Offstage( + offstage: !logic.isLoading.value, + child: const LoadingCenterWidget())), + ], + ], + ), + ); + } + + //无权限 + Widget _buildNoPermission(LiveDetailLogic logic) { + return Container( + color: Colors.black, + child: Stack( + fit: StackFit.expand, + children: [ + _buildCover(logic), + Container( + color: Colors.black.withValues(alpha: .7), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '温馨提示', + style: TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500), + ), + 12.sizeBoxH, + const Text('开通直播卡全站直播骚货任选~', + style: TextStyle(fontSize: 12, color: Colors.white)), + 18.sizeBoxH, + GestureDetector( + onTap: logic.onChargeAction, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xffffe8be), Color(0xffe5b764)], + ), + ), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: const Text('立即开通直播卡', + style: + TextStyle(fontSize: 14, color: Color(0xFF7b0000))), + ), + ), + ], + ), + ), + ], + ), + ); + } + + //主播离线 + Widget _buildOffline(LiveDetailLogic logic) { + return Container( + color: Colors.black, + child: Stack( + fit: StackFit.expand, + children: [ + _buildCover(logic), + Container( + color: Colors.black.withValues(alpha: .7), + alignment: Alignment.center, + child: const Text('当前主播离线,请前往观看其她热门主播喔~', + style: TextStyle(fontSize: 14, color: Colors.white)), + ), + ], + ), + ); + } + + //封面图 + Widget _buildCover(LiveDetailLogic logic) { + return NetworkImageLoader( + imageUrl: logic.anchor?.coverImg ?? '', + encrypt: false, + borderRadius: 0, + width: Get.width, + ); + } + + //热门直播推荐列表 + Widget _buildRecommend() { + return GetBuilder( + id: 'recommend', + builder: (logic) { + if (logic.loading) { + return const SliverToBoxAdapter(child: LoadingWidget()); + } + if (logic.recommendList.isEmpty) { + return const SliverToBoxAdapter( + child: SizedBox(height: 300, child: CErrorWidget())); + } + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 7, + childAspectRatio: 168 / 105, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final anchor = logic.recommendList[index]; + return LiveItemWidget( + showType: 5, + anchor: anchor, + action: () => logic.onChangeLiveAction(anchor), + ); + }, + childCount: logic.recommendList.length, + ), + ), + ); + }, + ); + } + + //返回按钮 + Widget _buildBack() { + return GestureDetector( + onTap: () => Get.back(), + child: Container( + padding: const EdgeInsets.only(left: 10, right: 2, top: 2, bottom: 2), + decoration: + const BoxDecoration(color: Colors.grey, shape: BoxShape.circle), + child: Icon(Icons.arrow_back_ios, + color: Colors.black.withValues(alpha: 0.8)), + ), + ); + } + + //主播信息 + Widget _buildAnchorInfo(LiveDetailLogic logic) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Column( + children: [ + Row( + children: [ + NetworkImageLoader( + imageUrl: logic.anchor?.coverImg ?? '', + width: 36, + height: 36, + borderRadius: 18, + encrypt: false, + ), + 4.sizeBoxW, + Text(logic.anchor?.name ?? '', + style: const TextStyle(fontSize: 14, color: Colors.white)), + ], + ), + 18.sizeBoxH, + Row( + children: [ + Text( + '${logic.anchor?.viewCount}人观众', + style: const TextStyle(fontSize: 12, color: Color(0xff999999)), + ), + const Spacer(), + GestureDetector( + onTap: () => logic.onShareAction(), + child: Row( + children: [ + Image.asset('live_share.png'.livePath, width: 24), + 2.sizeBoxW, + const Text('分享', + style: + TextStyle(fontSize: 12, color: Color(0xff999999))), + ], + ), + ), + ], + ), + ], + ), + ); + } + + //广告位 + Widget _buildAds() { + return AdsGridViewWidget( + 36, + accordingAdsType: true, + padding: const EdgeInsets.fromLTRB(16, 18, 0, 0), + ); + } + + //分割线 + Widget _buildDivider() { + return SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + height: .5, + color: Colors.black.withValues(alpha: .1), + ), + ); + } +} diff --git a/lib/hj_page/live/live_item_widget.dart b/lib/hj_page/live/live_item_widget.dart new file mode 100644 index 0000000..fb28830 --- /dev/null +++ b/lib/hj_page/live/live_item_widget.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import 'live_detail_page.dart'; +import 'live_model.dart'; +import 'live_widget.dart'; + +class LiveItemWidget extends StatelessWidget { + final int showType; //0-九宫格 1-六圆形 2-4宫格 3-六宫格 4-横向滑动 5-list + final int? index; //顺序 + final LiveAnchor? anchor; //主播信息 + final Function()? action; + + const LiveItemWidget({ + super.key, + this.showType = 0, + this.anchor, + this.index, + this.action, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + if (action != null) { + action?.call(); + } else { + Get.to(() => LiveDetailPage(anchor: anchor), + preventDuplicates: false); + } + }, + child: _buildContent(), + ); + } + + _buildContent() { + if (showType == 0) return _buildType0(); + if (showType == 1) return _buildType1(); + if (showType == 2) return _buildType2(); + if (showType == 3) return _buildType3(); + if (showType == 4) return _buildType4(); + if (showType == 5) return _buildType5(); + return Container(); + } + + _buildType0() { + return Stack( + children: [ + Positioned.fill( + child: NetworkImageLoader( + imageUrl: anchor?.coverImg ?? '', encrypt: false), + ), + Positioned( + left: 4, + right: 4, + bottom: 6, + child: Text( + anchor?.name ?? '', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w400), + )), + Positioned( + left: 0, + top: 0, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xffFFE356), + Color(0xffE11EFB), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(9), + bottomRight: Radius.circular(9), + ), + ), + child: Text( + '王牌主播', + style: TextStyle( + fontSize: 10, + color: Colors.white, + fontWeight: FontWeight.w400), + ), + )), + ], + ); + } + + _buildType1() { + return SizedBox( + height: 70, + child: Stack( + children: [ + Positioned( + left: 18, + right: 0, + top: 7, + bottom: 0, + child: Image.asset( + 'live_six_item_bg.webp'.livePath, + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 62, + height: 62, + child: ClipOval( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xffAE06FF), + Color(0xffE538A3), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + )), + padding: EdgeInsets.all(1), + child: Container( + clipBehavior: Clip.hardEdge, + margin: EdgeInsets.all(1), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.8), + shape: BoxShape.circle, + ), + padding: EdgeInsets.all(2), + child: ClipOval( + child: NetworkImageLoader( + imageUrl: anchor?.coverImg ?? '', encrypt: false), + ), + ), + )), + ), + Positioned( + bottom: 10, + child: AudioWaveView( + width: 2, + margin: 3, + color: Colors.white, + ), + ) + ], + ), + SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + anchor?.name ?? '', + style: TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w400), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 2), + Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0xffFD0563).withValues(alpha: .2), + blurRadius: 15.0, //阴影模糊程度 + spreadRadius: 1.0, + ), + ], + ), + child: Text( + '霸占TA>', + style: TextStyle( + fontSize: 10, + color: Color(0xff6A00FF), + fontWeight: FontWeight.w400), + ), + ), + ], + ), + ) + ], + ), + ], + ), + ); + } + + _buildType2() { + return Stack( + children: [ + Positioned.fill( + child: NetworkImageLoader( + imageUrl: anchor?.coverImg ?? '', encrypt: false), + ), + Positioned( + left: 6, + bottom: 6, + child: Text( + anchor?.name ?? '', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w400), + )), + if (index != null && index! < 3) + Positioned( + left: 0, + top: 0, + child: Image.asset( + 'cp_${(index ?? 0) + 1}.webp'.livePath, + height: 24, + )), + ], + ); + } + + _buildType3() { + return Stack( + children: [ + Positioned.fill( + child: Container( + padding: EdgeInsets.all(2), + child: NetworkImageLoader( + imageUrl: anchor?.coverImg ?? '', + encrypt: false, + borderRadius: 6, + ), + ), + ), + Positioned( + left: 6, + bottom: 6, + child: Text( + anchor?.name ?? '', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w400), + )), + ], + ); + } + + _buildType4() { + return Container( + padding: EdgeInsets.all(4), + child: _buildType5(), + ); + } + + _buildType5() { + return Stack( + children: [ + Positioned.fill( + child: NetworkImageLoader( + imageUrl: anchor?.coverImg ?? '', + encrypt: false, + borderRadius: 6, + ), + ), + Positioned( + left: 6, + bottom: 6, + child: Text( + anchor?.name ?? '', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w400), + )), + Positioned( + right: 6, + top: 6, + height: 16, + child: Row( + children: [ + Container( + height: 8, + width: 8, + decoration: BoxDecoration( + color: (anchor?.isOnline ?? false) + ? Color(0xff0AEBED) + : Color(0xff656565), + borderRadius: BorderRadius.circular(4), + ), + ), + SizedBox(width: 4), + Text( + (anchor?.isOnline ?? false) ? '直播中' : '离线', + style: TextStyle( + fontSize: 10, + color: Colors.white, + fontWeight: FontWeight.w400), + ) + ], + )), + Positioned( + left: 6, + top: 6, + child: Container( + height: 16, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: .3), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${anchor?.viewCount ?? 0}人观众', + style: TextStyle( + fontSize: 10, + color: Colors.white, + fontWeight: FontWeight.w400), + ), + )), + ], + ); + } +} diff --git a/lib/hj_page/live/live_main_logic.dart b/lib/hj_page/live/live_main_logic.dart new file mode 100644 index 0000000..516f5e1 --- /dev/null +++ b/lib/hj_page/live/live_main_logic.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/live/live_service.dart'; + +import 'live_model.dart'; + +//直播主逻辑 +class LiveMainLogic extends GetxController with GetTickerProviderStateMixin { + TabController? tabController; + LiveMainModel? liveMainModel; + List liveTabs = []; + + @override + void onReady() { + super.onReady(); + loadData(); + } + + @override + void onClose() { + tabController?.dispose(); + super.onClose(); + } + + //获取媒体详情 + loadData() async { + liveMainModel = await LiveService.getLiveModuleList(); + if (liveMainModel != null) { + if (liveMainModel?.module != null && + liveMainModel?.module?.isNotEmpty == true) { + liveTabs.clear(); + liveTabs.add(LiveModule(id: '-1', title: '推荐')); //添加推荐数据 + liveTabs.addAll(liveMainModel?.module ?? []); + tabController = TabController( + initialIndex: 0, + length: liveTabs.length, + vsync: this, + ); + } + } + + update(); + } +} diff --git a/lib/hj_page/live/live_main_page.dart b/lib/hj_page/live/live_main_page.dart new file mode 100644 index 0000000..5b70ef9 --- /dev/null +++ b/lib/hj_page/live/live_main_page.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import 'live_main_logic.dart'; +import 'live_sub_page.dart'; + +//直播首页 +class LiveMainPage extends StatelessWidget { + static bool broadcast = false; //开启直播模块开关 + static bool jumpLiveLink = false; //直播内链跳转初始化 + + const LiveMainPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: LiveMainLogic(), + builder: (logic) { + if (logic.liveMainModel == null) return const LoadingCenterWidget(); + return Column( + children: [ + _buildTabBar(logic), + Expanded( + child: TabBarView( + controller: logic.tabController, + children: List.generate( + logic.liveTabs.length, + (index) => + LiveSubPage(tagData: logic.liveTabs[index]).keepAlive, + ), + ), + ), + ], + ); + }, + ); + } + + //国家分类 + Widget _buildTabBar(LiveMainLogic logic) { + return SizedBox( + height: 44, + child: TabBar( + controller: logic.tabController, + tabs: List.generate( + logic.liveTabs.length, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 7), + child: Text(logic.liveTabs[index].title ?? ''), + ), + ), + labelPadding: const EdgeInsets.symmetric(horizontal: 9), + tabAlignment: TabAlignment.start, + isScrollable: true, + labelColor: Colors.white.withValues(alpha: .9), + labelStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + unselectedLabelColor: Colors.white.withValues(alpha: .45), + unselectedLabelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + indicatorPadding: const EdgeInsets.only(top: -4), + indicator: CustomIndicator( + height: 2, + width: 16, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(1), + topRight: Radius.circular(1), + ), + color: AppColors.actionRed, + ), + ), + ); + } +} diff --git a/lib/hj_page/live/live_model.dart b/lib/hj_page/live/live_model.dart new file mode 100644 index 0000000..aafa48c --- /dev/null +++ b/lib/hj_page/live/live_model.dart @@ -0,0 +1,103 @@ +class LiveMainModel { + List? module; + List? recom; + + LiveMainModel({this.module, this.recom}); + + LiveMainModel.fromJson(Map json) { + if (json['module'] != null) { + module = []; + json['module'].forEach((v) { + module!.add(new LiveModule.fromJson(v)); + }); + } + if (json['recom'] != null) { + recom = []; + json['recom'].forEach((v) { + recom!.add(new LiveRecom.fromJson(v)); + }); + } + } +} + +class LiveModule { + String? id; + String? title; + + LiveModule({this.id, this.title}); + + LiveModule.fromJson(Map json) { + id = json['id']; + title = json['title']; + } +} + +class LiveRecom { + List? anchors; + String? recoShowType; + + LiveRecom({this.anchors, this.recoShowType}); + + LiveRecom.fromJson(Map json) { + if (json['anchors'] != null) { + anchors = []; + json['anchors'].forEach((v) { + anchors!.add(new LiveAnchor.fromJson(v)); + }); + } + recoShowType = json['recoShowType']; + } +} + +class LiveAnchor { + String? id; + String? name; + String? coverImg; + String? url; + String? country; + int? vidWidth; + int? vidHeight; + bool? isOnline; + bool? isNew; + int? viewCount; + + LiveAnchor( + {this.id, + this.name, + this.coverImg, + this.url, + this.country, + this.vidWidth, + this.vidHeight, + this.isOnline, + this.isNew, + this.viewCount}); + + LiveAnchor.fromJson(Map json) { + id = json['id']; + name = json['name']; + coverImg = json['coverImg']; + url = json['url']; + country = json['country']; + vidWidth = json['vidWidth']; + vidHeight = json['vidHeight']; + isOnline = json['isOnline']; + isNew = json['isNew']; + viewCount = json['viewCount']; + } + + Map toJson() { + final Map data = new Map(); + data['id'] = this.id; + data['name'] = this.name; + data['coverImg'] = this.coverImg; + data['url'] = this.url; + data['country'] = this.country; + data['vidWidth'] = this.vidWidth; + data['vidHeight'] = this.vidHeight; + data['isOnline'] = this.isOnline; + data['isNew'] = this.isNew; + data['viewCount'] = this.viewCount; + return data; + } +} diff --git a/lib/hj_page/live/live_service.dart b/lib/hj_page/live/live_service.dart new file mode 100644 index 0000000..3b302cf --- /dev/null +++ b/lib/hj_page/live/live_service.dart @@ -0,0 +1,79 @@ +import 'package:hgdj/hj_model/list_base_model.dart'; +import 'package:hgdj/hj_page/live/live_model.dart'; +import 'package:hgdj/tools_base/net/http_manager.dart'; +import 'package:hgdj/tools_base/net/net_manager.dart'; + +class LiveService { + //获取直播模块 + static Future getLiveModuleList() async { + int time = + netManager.getFixedCurTime().toUtc().millisecondsSinceEpoch ~/ 1000; + final param = {'time': time}; + final result = await httpManager.fetchResponseByPOST( + '/live/module/list/91porn', + param: param, + jsonTransformation: (json) => LiveMainModel.fromJson(json), + ); + return result.data; + } + + //获取直播模块 + static Future?> getLiveAnchorList({ + String? id, + int? pageNumber, + int? pageSize, + }) async { + int time = + netManager.getFixedCurTime().toUtc().millisecondsSinceEpoch ~/ 1000; + final param = { + 'time': time, + 'id': id, + 'pageNumber': pageNumber, + 'pageSize': 20, + }; + final result = await httpManager.fetchResponseByPOST( + '/live/anchor/list/91porn', + param: param, + jsonTransformation: (json) => ListBaseModel.fromJson(json), + ); + return result.data; + } + + //获取直播详情模块 + static Future getLiveAnchorDetail({String? id}) async { + int time = + netManager.getFixedCurTime().toUtc().millisecondsSinceEpoch ~/ 1000; + final param = { + 'time': time, + 'id': id, + }; + final result = await httpManager.fetchResponseByPOST( + '/live/anchor/watch/91porn', + param: param, + jsonTransformation: (json) => LiveAnchor.fromJson(json), + ); + return result.data; + } + + //获取直播详情推荐列表 + static Future?> getLiveAnchorRecom({ + String? country, + int? pageNumber, + int? pageSize, + }) async { + int time = + netManager.getFixedCurTime().toUtc().millisecondsSinceEpoch ~/ 1000; + final param = { + 'time': time, + 'country': country, + 'pageNumber': pageNumber, + 'pageSize': pageSize, + }; + final result = await httpManager.fetchResponseByPOST( + '/live/anchor/recom/91porn', + param: param, + jsonTransformation: (json) => ListBaseModel.fromJson(json), + ); + return result.data; + } +} diff --git a/lib/hj_page/live/live_sub_logic.dart b/lib/hj_page/live/live_sub_logic.dart new file mode 100644 index 0000000..e89f2f7 --- /dev/null +++ b/lib/hj_page/live/live_sub_logic.dart @@ -0,0 +1,80 @@ +import 'package:hgdj/hj_model/list_base_model.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/live/live_service.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../mine/mine_vip/mine_charge_vip_page.dart'; +import 'live_main_logic.dart'; +import 'live_model.dart'; + +//直播亚模块 +class LiveSubLogic extends GetxController { + LiveModule? tagData; + int currentPage = 1; + bool loading = true; + List liveList = []; // + RefreshController? controller; + bool get isRecom => tagData?.id == '-1'; //是否是推荐模块 + LiveMainLogic get mainLogic => Get.find(); + LiveMainModel? liveMainModel; + + LiveSubLogic({this.tagData}); + + @override + onReady() { + super.onReady(); + //默认数据 + liveMainModel = mainLogic.liveMainModel; + loadData(); + } + + //获取专题数据 + loadData({int pageNum = 1, bool showLoading = false}) async { + if (isRecom) { + loadRecommendData(); + update(); + } else { + loadCountryAnchor(pageNum: pageNum); + } + } + + //推荐直播列表 + loadRecommendData() async { + final result = await LiveService.getLiveModuleList(); + if (result != null) { + liveMainModel = result; + } + controller?.refreshCompleted(); + update(); + } + + //tag直播列表 + loadCountryAnchor({int pageNum = 1, bool showLoading = false}) async { + ListBaseModel? liveMainModels = + await LiveService.getLiveAnchorList( + id: tagData?.id ?? '', + pageNumber: pageNum, + ); + loading = false; + if (liveMainModels != null) { + currentPage = pageNum; + if (currentPage == 1) liveList.clear(); + if (liveMainModels.list != null && + liveMainModels.list?.isNotEmpty == true) { + liveList.addAll(liveMainModels.list ?? []); + } + } + update(); + liveMainModels?.hasNext == true + ? controller?.loadComplete() + : controller?.loadNoData(); + controller?.refreshCompleted(); + } + + loadMoreData() => loadData(pageNum: currentPage + 1); + + //跳转充值 + onChargeAction() { + Get.to(MineChargeVipPage(), preventDuplicates: false); + } +} diff --git a/lib/hj_page/live/live_sub_page.dart b/lib/hj_page/live/live_sub_page.dart new file mode 100644 index 0000000..8154da6 --- /dev/null +++ b/lib/hj_page/live/live_sub_page.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/live/live_item_widget.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/widget/card_swiper/src/swiper.dart'; + +import 'live_model.dart'; +import 'live_sub_logic.dart'; + +//直播子列表页面 +class LiveSubPage extends StatefulWidget { + final LiveModule? tagData; + + const LiveSubPage({super.key, this.tagData}); + + @override + State createState() => _LiveSubPageState(); +} + +class _LiveSubPageState extends State { + String get _tag => widget.tagData?.id ?? ''; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage('live_main_bg.webp'.livePath), fit: BoxFit.fill), + ), + child: GetBuilder( + init: LiveSubLogic(tagData: widget.tagData), + tag: _tag, + builder: (logic) => pullYsRefresh( + enablePullUp: !logic.isRecom, + onInit: (c) => logic.controller = c, + onRefresh: (c) => logic.loadData(), + onLoading: (c) => logic.loadMoreData(), + child: _buildContent(logic), + ), + ), + ); + } + + Widget _buildContent(LiveSubLogic logic) { + //列表样式 + if (!logic.isRecom) { + return CustomScrollView( + slivers: [_buildAdBanner(), _buildAnchorGrid(logic)]); + } + //推荐样式 + if (logic.liveMainModel?.recom == null) return const LoadingCenterWidget(); + if (logic.liveMainModel?.recom?.isEmpty == true) { + return CErrorWidget(retryOnTap: () => logic.loadData()); + } + return CustomScrollView(slivers: _buildRecomItems(logic)); + } + + //广告 banner + Widget _buildAdBanner() { + return SliverToBoxAdapter( + child: AdsGridViewWidget( + 35, + accordingAdsType: true, + padding: const EdgeInsets.fromLTRB(16, 15, 16, 15), + ), + ); + } + + //国家/标签主播列表 + Widget _buildAnchorGrid(LiveSubLogic logic) { + if (logic.loading) { + return const SliverToBoxAdapter( + child: SizedBox(height: 200, child: LoadingCenterWidget())); + } + if (logic.liveList.isEmpty) { + return SliverToBoxAdapter( + child: CErrorWidget(retryOnTap: () => logic.loadData())); + } + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 7, + childAspectRatio: 168 / 105, + ), + delegate: SliverChildBuilderDelegate( + (_, index) => + LiveItemWidget(showType: 5, anchor: logic.liveList[index]), + childCount: logic.liveList.length, + ), + ), + ); + } + + //推荐页各区块(首块额外叠充值入口) + List _buildRecomItems(LiveSubLogic logic) { + final recomList = logic.liveMainModel?.recom ?? []; + return List.generate(recomList.length, (i) { + Widget section = _buildRecomSection(recomList[i]); + if (i == 0) section = _wrapFirstSection(logic, section); + return SliverToBoxAdapter(child: section); + }); + } + + //按 recoShowType 分发区块样式 + Widget _buildRecomSection(LiveRecom recom) { + switch (recom.recoShowType) { + case 'nine_grid': + return _buildGridSection(recom, + bg: 'live_section_bg_1.webp', + title: 'nine_grid_bg.webp', + titleHeight: 45, + crossAxisCount: 3, + mainSpacing: 12, + crossSpacing: 5, + aspectRatio: 1, + showType: 0); + case 'six_round': + return _buildGridSection(recom, + bg: 'live_section_bg_2.webp', + title: 'six_round_bg.webp', + titleHeight: 45, + crossAxisCount: 2, + mainSpacing: 0, + crossSpacing: 6, + aspectRatio: 168 / 85, + showType: 1, + bottomSpace: 10); + case 'four_grid': + return _buildGridSection(recom, + bg: 'live_section_bg_3.webp', + title: 'four_grid_bg.webp', + titleHeight: 45, + crossAxisCount: 2, + mainSpacing: 12, + crossSpacing: 7, + aspectRatio: 1, + showType: 2, + bottomSpace: 18); + case 'six_grid': + return _buildGridSection(recom, + bg: 'live_section_bg_4.webp', + title: 'six_grid_bg.webp', + titleHeight: 36, + crossAxisCount: 3, + mainSpacing: 12, + crossSpacing: 5, + aspectRatio: 1, + showType: 3); + case 'horizontal_slide': + return _buildHorizontalSlide(recom); + case 'list': + return _buildRecomList(recom.anchors); + default: + return const SizedBox.shrink(); + } + } + + //首个区块叠加充值入口 + Widget _wrapFirstSection(LiveSubLogic logic, Widget section) { + return Stack( + alignment: Alignment.topCenter, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [178.sizeBoxH, section, 10.sizeBoxH]), + Positioned( + top: 20, + child: GestureDetector( + onTap: () => logic.onChargeAction(), + child: Image.asset('live_charge.webp'.livePath, height: 148), + ), + ), + ], + ); + } + + //宫格类区块(九宫格/六圆形/四宫格/六宫格 共用) + Widget _buildGridSection( + LiveRecom? recom, { + required String bg, + required String title, + required double titleHeight, + required int crossAxisCount, + required double mainSpacing, + required double crossSpacing, + required double aspectRatio, + required int showType, + double bottomSpace = 0, + }) { + final anchors = recom?.anchors; + return Container( + decoration: BoxDecoration( + image: + DecorationImage(image: AssetImage(bg.livePath), fit: BoxFit.fill), + ), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.only(left: 12, right: 12, top: 4, bottom: 34), + child: Column( + children: [ + 18.sizeBoxH, + Image.asset(title.livePath, height: titleHeight), + 18.sizeBoxH, + GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: mainSpacing, + crossAxisSpacing: crossSpacing, + childAspectRatio: aspectRatio, + ), + itemCount: anchors?.length ?? 0, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + //index 仅 showType==2 用于渲染前三角标,其余样式忽略 + itemBuilder: (_, index) => LiveItemWidget( + showType: showType, anchor: anchors?[index], index: index), + ), + if (bottomSpace > 0) bottomSpace.sizeBoxH, + ], + ), + ); + } + + //横向滑动 + Widget _buildHorizontalSlide(LiveRecom? recom) { + final anchors = recom?.anchors; + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + const Color(0xffFF8E8E).withValues(alpha: 0), + const Color(0xffFF8E8E).withValues(alpha: .2), + const Color(0xffFF8E8E).withValues(alpha: 0), + ], + ), + ), + child: Column( + children: [ + 18.sizeBoxH, + Container( + height: 175, + alignment: Alignment.bottomRight, + child: Swiper( + autoplay: true, + autoplayDelay: 5000, + viewportFraction: 0.8, + scale: 0.93, + itemCount: anchors?.length ?? 0, + itemBuilder: (c, index) => + LiveItemWidget(showType: 4, anchor: anchors?[index]), + ), + ), + ], + ), + ); + } + + //推荐无限下滑列表 + Widget _buildRecomList(List? anchors) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 28), + child: GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 5, + childAspectRatio: 168 / 105, + ), + itemCount: anchors?.length ?? 0, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (_, index) => + LiveItemWidget(showType: 5, anchor: anchors?[index]), + ), + ); + } +} diff --git a/lib/hj_page/live/live_widget.dart b/lib/hj_page/live/live_widget.dart new file mode 100644 index 0000000..b71fa69 --- /dev/null +++ b/lib/hj_page/live/live_widget.dart @@ -0,0 +1,176 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +/// 音频波形动画组件 +/// 用于显示音频播放时的动态波形效果 +class AudioWaveView extends StatefulWidget { + /// 波形高度 + final double height; + + /// 波形颜色 + final Color color; + + /// 动画持续时间 + final Duration duration; + + /// 动画曲线 + final Curve curve; + + /// 波形条之间的间距 + final double margin; + + /// 波形条的宽度 + final double width; + + /// 波形条的数量 + final int barCount; + + const AudioWaveView({ + super.key, + this.height = 12, + this.margin = 3, + this.width = 1.5, + this.color = const Color(0xffF68804), + this.duration = const Duration(seconds: 5), + this.curve = Curves.easeInOut, + this.barCount = 3, + }); + + @override + State createState() => _AudioWaveViewState(); +} + +class _AudioWaveViewState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late List> _animations; + + @override + void initState() { + super.initState(); + _initializeAnimations(); + } + + /// 初始化动画 + void _initializeAnimations() { + _controller = AnimationController( + vsync: this, + duration: widget.duration, + ); + + _animations = _createWaveAnimations(); + _controller.repeat(); + } + + /// 创建波形动画列表 + List> _createWaveAnimations() { + const int sequenceCount = 15; + final List> animations = []; + + for (int i = 0; i < widget.barCount; i++) { + final source = _generateRandomSequence(sequenceCount); + + final tweenSequence = TweenSequence( + List.generate(sequenceCount, (index) { + return TweenSequenceItem( + tween: Tween( + begin: source[index], + end: source[index + 1], + ), + weight: 100.0 / sequenceCount, + ); + }), + ); + + animations.add( + tweenSequence.animate( + CurvedAnimation( + parent: _controller, + curve: widget.curve, + ), + ), + ); + } + + return animations; + } + + /// 生成随机序列 + List _generateRandomSequence(int count) { + final random = Random(); + final source = List.generate(count, (_) => random.nextDouble()); + source.add(source.first); // 确保循环 + return source; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: _buildWaveBars(), + ); + }, + ); + } + + /// 构建波形条列表 + List _buildWaveBars() { + final List bars = []; + + for (int i = 0; i < widget.barCount; i++) { + if (i > 0) { + bars.add(SizedBox(width: widget.margin)); + } + + bars.add( + WaveBar( + color: widget.color, + width: widget.width, + height: _animations[i].value * widget.height, + ), + ); + } + + return bars; + } +} + +/// 单个波形条组件 +class WaveBar extends StatelessWidget { + final double width; + final double height; + final Color color; + + const WaveBar({ + super.key, + required this.width, + required this.height, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: height, + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(width / 2), + ), + ), + ); + } +} diff --git a/lib/hj_page/main_page/drama_tip_bubble.dart b/lib/hj_page/main_page/drama_tip_bubble.dart new file mode 100644 index 0000000..28b098b --- /dev/null +++ b/lib/hj_page/main_page/drama_tip_bubble.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/light_model.dart'; +import 'package:hgdj/hj_utils/store_keys.dart'; + +import 'main_logic.dart'; + +/// 「AI爽剧来袭」引导气泡:贴底栏上沿、尾巴指着短剧 tab,原地上下轻跳。 +/// 亮出来就算数——装机维度只亮这一次(落 KV),不用等用户点; +/// 后台把 ping 的 entryPopupEnabled 置 false 可全局关掉 +class DramaTipBubble extends StatefulWidget { + const DramaTipBubble({super.key}); + + /// 短剧在底栏的下标。暗网排在它后面,darkWebEnable 关不关都不影响 + static const dramaTab = 1; + + static final visible = ValueNotifier(false); // 气泡亮不亮 + + static bool _seen = false; //本次启动是否已进过短剧 tab + + /// MainPage 创建时调一次;ping 关掉或以前亮过就不再亮 + static Future start() async { + if (!Config.entryPopupEnabled) return; + if (await lightKV.getBool(StoreKeys.DRAMA_TIP_SHOWN) ?? false) return; + if (_seen) return; //读盘这会儿已经进过短剧了(深链直达),这次不亮也不算数,下次冷启动照亮 + visible.value = true; + lightKV.setBool(StoreKeys.DRAMA_TIP_SHOWN, true); //亮出来就记下,不等用户点 + } + + /// 进短剧 tab 就收起,只管本次启动;算不算数在 [start] 亮出来那一刻就定了。 + /// 可能早于 [start] 的读盘,所以要留下 [_seen] 挡住它 + static void hide() { + visible.value = false; + _seen = true; + } + + @override + State createState() => _DramaTipBubbleState(); +} + +class _DramaTipBubbleState extends State + with SingleTickerProviderStateMixin { + //上下轻跳:0→1 映射成向上 0→4,来回循环 + late final _ani = AnimationController( + vsync: this, duration: const Duration(milliseconds: 600)); + + @override + void initState() { + super.initState(); + if (DramaTipBubble.visible.value) _ani.repeat(reverse: true); + DramaTipBubble.visible.addListener(_onVisibleChanged); + } + + @override + void dispose() { + DramaTipBubble.visible.removeListener(_onVisibleChanged); + _ani.dispose(); + super.dispose(); + } + + //收起要停 ticker:repeat 的控制器不停会一直请求 vsync 帧 + void _onVisibleChanged() { + DramaTipBubble.visible.value ? _ani.repeat(reverse: true) : _ani.stop(); + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + if (!DramaTipBubble.visible.value) return const SizedBox.shrink(); + final logic = Get.find(); + //底栏左右各 12 内边距,剩下的等分给各 tab;51.8 是尾巴尖在图里的横向位置 + final tabWidth = (Get.width - 24) / logic.tabs.length; + final tailCenter = 12 + tabWidth * (DramaTipBubble.dramaTab + 0.5); + return Row( + children: [ + SizedBox(width: tailCenter - 51.8), + InkWell( + enableFeedback: false, + onTap: () => logic.jumpToPage(DramaTipBubble.dramaTab), + child: AnimatedBuilder( + animation: _ani, + builder: (_, child) => Transform.translate( + offset: Offset(0, -4 * Curves.easeInOut.transform(_ani.value)), + child: child, + ), + child: + Image.asset('drama_tip.webp'.homePath, width: 131, height: 35), + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/main_page/main_logic.dart b/lib/hj_page/main_page/main_logic.dart new file mode 100644 index 0000000..a8c545d --- /dev/null +++ b/lib/hj_page/main_page/main_logic.dart @@ -0,0 +1,282 @@ +import 'dart:async'; + +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_model/home/plate_model.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/free_play_manager.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +import '../../tools_base/event_bus/event_bus_util.dart'; +import '../../tools_base/event_bus/events.dart'; +import '../../tools_base/toast.dart'; +import '../cartoon/cartoon_sub_module_logic.dart'; +import '../community/community/community_module_logic.dart'; +import '../community/community_main_page_page/community_main_logic.dart'; +import '../home/home_main_logic.dart'; +import '../home/provider/home_update_marker_provider.dart'; +import '../mine/mine_vip/vip_product_manager.dart'; +import '../short_video/short_main_video_logic.dart'; +import 'drama_tip_bubble.dart'; +import 'main_page.dart'; +import 'provider/msg_provider.dart'; + +class MainPageBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => MainPageLogic()); + } +} + +/// 亚模块 id 在底栏里的落点 +class ModuleModel { + final int tab; //底部 tab 下标 + final int sub; //顶部 tab 下标 + final int module; //顶部 tab 下的亚模块下标 + + ModuleModel({required this.tab, required this.sub, required this.module}); +} + +//tab数据模型 +class MainTabModel { + final String title; //title + final String imageNor; //nor图片 + final String imageSel; //选中图片 + + MainTabModel({ + required this.title, + required this.imageNor, + required this.imageSel, + }); +} + +class MainPageLogic extends GetxController { + final tabs = [ + MainTabModel( + title: '主页', + imageNor: 'home_0_0.png'.homePath, + imageSel: 'home_0_1.png'.homePath, + ), + MainTabModel( + title: '短剧', + imageNor: 'home_1_0.png'.homePath, + imageSel: 'home_1_1.png'.homePath, + ), + MainTabModel( + title: Config.darkWebIconName?.isNotEmpty == true + ? Config.darkWebIconName! + : '暗网', + imageNor: 'home_2_0.png'.homePath, + imageSel: 'home_2_1.png'.homePath, + ), + MainTabModel( + title: 'AI', + imageNor: 'home_3_0.png'.homePath, + imageSel: 'home_3_1.png'.homePath, + ), + MainTabModel( + title: '社区', + imageNor: 'home_4_0.png'.homePath, + imageSel: 'home_4_1.png'.homePath, + ), + MainTabModel( + title: '我的', + imageNor: 'home_5_0.png'.homePath, + imageSel: 'home_5_1.png'.homePath, + ), + ]; + + /// 当前底部 tab 下标:内容页(LazyIndexedStack)和底栏选中态的唯一真相源。 + /// 启动固定落在短剧 + final curPageIndex = DramaTipBubble.dramaTab.obs; + + final shortIndex = (-1).obs; // -1 = 用短剧频道的默认页(AI短剧) + final darkInitTabId = Rx(null); // 暗网默认亚模块 id + final communityIndex = 0.obs; // 社区模块默认模块 + final communitySubIndex = 0.obs; // 社区模块默认亚模块 + + /// 亚模块id -<主>tab - <模块 - 亚模块> + final moduleMap = {}; + + late StreamSubscription _yinseSub; + late StreamSubscription _loginSub; + DateTime? _lastBackAt; //上次返回键点击时间 + + //暗网 tab 关掉后,排在它后面的 tab 整体前移一位 + int get aiTab => Config.darkWebEnable ? 3 : 2; + + int get communityTab => Config.darkWebEnable ? 4 : 3; + + @override + void onInit() { + super.onInit(); + if (!Config.darkWebEnable) { + tabs.removeAt(2); + } + MineMsgProvider().loadData(); + HomeUpdateMarkerProvider().loadData(); + + //启动就落在短剧:引导气泡不该再亮(这里早于 DramaTipBubble.start 的读盘) + if (curPageIndex.value == DramaTipBubble.dramaTab) DramaTipBubble.hide(); + + _yinseSub = eventBus.on(_onYinse); + _loginSub = eventBus.on(_onReLogin); + + /// 组装所有 + Future(() { + final app = Config.plateModule; + if (app == null) return; + void put(List modules, int tab, int sub) { + modules.asMap().forEach((i, e) => + moduleMap[e.id ?? ''] = ModuleModel(tab: tab, sub: sub, module: i)); + } + + put(app.homePage, 0, 0); + // 暗网 tab 关掉时不登记其亚模块:没有对应 tab,再按 tab=2 跳会落到 AI 页 + if (Config.darkWebEnable) put(app.deepWeb, 2, 0); + put(app.community, communityTab, 0); + put(app.picsUi, communityTab, 1); + put(app.gameUi, communityTab, 2); + put(app.novel, communityTab, 3); + }); + } + + void _onReLogin(ReLoginEvent event) { + _backToRoot(); + jumpToPage(0); + globalStore.refreshWallet(); + MineMsgProvider() + .loadData(); //内含 refreshPayPopup:重拉支付分层配置,即时刷新浮窗/我的横幅/播放器横幅 + unawaited(FreePlayManager().refresh()); //切号:免费观看次数是账户维度的,不重拉会一直用着上个账号的次数 + HomeUpdateMarkerProvider().loadData(); + presaleProvider.refreshStatus(); //刷新预售权益 + vipProductManager.reloadForUser(); //切号:按新用户权限重拉会员卡(预售等卡随权限/购买状态返回不同) + _homeLogic()?.tabCtr?.index = 0; + } + + void _onYinse(YinseinnerModel model) { + switch (model.routeName) { + case 'navigation_bar': //导航栏 亚模块菜单弹出 + _openNavBar(); + case 'module': + case 'community_module': //媒体库亚模块分类 + _backToRoot(); + _matchModule(model.id); + case 'live_streaming': //直播 + _backToRoot(); + _skipToLive(); + case 'ai': + _backToRoot(); + _skipToAi(); + } + } + + //首页/暗网共用 HomeMainLogic,靠 tag 区分;页面还没建时返回 null + HomeMainLogic? _homeLogic([String tag = 'HomeMainLogic']) => + Get.isRegistered(tag: tag) + ? Get.find(tag: tag) + : null; + + void _openNavBar() { + _backToRoot(); + jumpToPage(0); + _homeLogic()?.openEndDrawer(); + } + + //跳转直播 + void _skipToLive() { + jumpToPage(1); + if (Get.isRegistered()) { + Get.find().jumpToLive(); + } else { + shortIndex.value = 0; //直播固定在第一个 + } + } + + //跳转ai + void _skipToAi() => jumpToPage(aiTab); + + void _matchModule(String? id) { + if (id == null) return; + final module = moduleMap[id]; + if (module == null) return; + //首页和暗网 tab 可自动排序, 不用 HomeModuleProvider 处理 + if (module.tab == 0) { + _homeLogic()?.gotoTabId(id); + jumpToPage(0); + } else if (module.tab == 2) { + //tab==2 只在暗网开启时才登记进 moduleMap(见 onInit),所以这里下标可直接写 2 + final darkLogic = _homeLogic('HomeMainLogic_dark'); + if (darkLogic != null) { + darkLogic.gotoTabId(id); + } else { + darkInitTabId.value = id; + } + jumpToPage(2); + } else if (module.tab == communityTab) { + _skipToCommunity(module); + jumpToPage(communityTab); + } + } + + //社区对应 logic 已建就直接切,没建就记下来等页面自己读 + void _skipToCommunity(ModuleModel module) { + if (Get.isRegistered()) { + Get.find().jumpSubModule(module.sub); + if (module.sub == 0) { + if (Get.isRegistered()) { + Get.find().skipToTabIndex(module.module); + return; + } + } else { + final tag = [ + "", + MediaStyle.Pic.name, + MediaStyle.Game.name, + MediaStyle.Novel.name + ][module.sub]; + if (Get.isRegistered(tag: tag)) { + Get.find(tag: tag) + .skipToTabIndex(module.module); + return; + } + } + } + communityIndex.value = module.sub; + communitySubIndex.value = module.module; + } + + //返回首页 + void _backToRoot() => + Get.until((route) => route.settings.name == MainPage.routeName); + + /// 切 tab:内容页和底栏选中态都监听 curPageIndex,改它一处即可 + void jumpToPage(int index) { + curPageIndex.value = index; + if (index == DramaTipBubble.dramaTab) DramaTipBubble.hide(); //进了短剧就别再指着它 + } + + /// 双击返回退出:2 秒内连按两次返回键才真正退出 + bool confirmExit() { + final now = DateTime.now(); + // 物理键,两次间隔大于 2 秒,退出请求无效 + if (_lastBackAt == null || + now.difference(_lastBackAt!) > const Duration(seconds: 2)) { + _lastBackAt = now; + showToast('再次返回即可退出'); + return false; + } + // 退出请求有效 + _lastBackAt = null; + return true; + } + + @override + void onClose() { + _yinseSub.cancel(); + _loginSub.cancel(); + super.onClose(); + } +} diff --git a/lib/hj_page/main_page/main_page.dart b/lib/hj_page/main_page/main_page.dart new file mode 100644 index 0000000..1708b24 --- /dev/null +++ b/lib/hj_page/main_page/main_page.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/short_video/short_main_video_page.dart'; +import 'package:hgdj/tools_base/widget/lazy_indexed_stack.dart'; + +import '../../alert/vip_guide/guide_push_banner.dart'; +import '../../config/config.dart'; +import '../../main.dart'; +import '../ai/ai_home_page.dart'; +import '../community/community_main_page_page/community_main_page.dart'; +import '../home/dark_home_page.dart'; +import '../home/home_main_page.dart'; +import '../home/widget/home_txt_marquee.dart'; +import '../mine/home_mine_page.dart'; +import 'drama_tip_bubble.dart'; +import 'main_logic.dart'; +import 'main_tabbar.dart'; + +class MainPage extends StatefulWidget { + static const routeName = '/MainPage'; + + const MainPage({super.key}); + + @override + State createState() => _MainPageState(); +} + +class _MainPageState extends State with RouteAware { + // 懒加载:MainPage 可能在 binding 未跑时就被构造, + // 用 getter 让 logic 只在 build 访问时才 Get.find,避免构造期 not found 崩溃 + MainPageLogic get logic => Get.find(); + + @override + void initState() { + super.initState(); + GuidePushBanner.start(); //进 app 满3分钟后开始推 VIP「特享内容」横幅 + DramaTipBubble.start(); //「AI爽剧来袭」引导气泡,装机维度只亮一次 + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route != null) routeObserver.subscribe(this, route); + } + + /// 二级页面/弹窗关闭回到本页:满3分钟后每次都拉一次「特享内容」,有数据就弹横幅 + @override + void didPopNext() { + super.didPopNext(); + GuidePushBanner.onBackToMainPage(); + } + + @override + void dispose() { + routeObserver.unsubscribe(this); + GuidePushBanner.stop(); //退登/重进流程销毁本页时,别留着定时任务空跑 + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (logic.confirmExit()) SystemNavigator.pop(); + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + body: Column( + children: [ + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + _buildPages(), + //引导气泡贴着底栏上沿,尾巴指短剧 tab + const Positioned( + left: 0, right: 0, bottom: 0, child: DramaTipBubble()), + ], + ), + ), + const MainPageTabbar(), + ], + ), + ), + ); + } + + Widget _buildPages() { + return Obx(() { + final cur = logic.curPageIndex.value; + return LazyIndexedStack( + index: cur, + children: [ + // 主页 + Stack( + fit: StackFit.expand, + children: [ + const HomeMainPage(), + _bottomMarquee(), + ], + ), + // 短视频 + Obx(() => ShortVideoMainPage(index: logic.shortIndex.value)), + // 暗网 + if (Config.darkWebEnable) + Obx(() => DarkHomePage(defaultId: logic.darkInitTabId.value)), + // AI + const AiHomePage(), + // 社区 + Stack( + children: [ + Obx( + () => CommunityMainPage( + index: logic.communityIndex.value, + subTabIndex: logic.communitySubIndex.value, + ), + ), + _bottomMarquee(), + ], + ), + // 我的(IndexedStack 天然保活,不需要 keepAlive) + const HomeMinePage(), + ], + ); + }); + } + + /// 首页/社区底部的文字跑马灯 + Widget _bottomMarquee() { + return Positioned( + bottom: 0, + left: 0, + right: 0, + child: HomeTxtMarQuee(typeValue: 1), + ); + } +} diff --git a/lib/hj_page/main_page/main_tabbar.dart b/lib/hj_page/main_page/main_tabbar.dart new file mode 100644 index 0000000..b844fb8 --- /dev/null +++ b/lib/hj_page/main_page/main_tabbar.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/main_page/provider/bottom_bar_provider.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../config/config.dart'; +import '../../../tools_base/event_bus/event_bus_util.dart'; +import '../../../tools_base/event_bus/events.dart'; +import '../home/provider/home_update_marker_provider.dart'; +import '../mine/home_mine_logic.dart'; +import 'main_logic.dart'; + +/// 底部导航栏:按 logic.tabs 渲染各 tab,选中态随 logic.curPageIndex 刷新 +class MainPageTabbar extends StatelessWidget { + const MainPageTabbar({super.key}); + + MainPageLogic get logic => Get.find(); + + void _selectIndex(int index) { + final switchingToHome = index == 0 && logic.curPageIndex.value != 0; + logic.jumpToPage(index); + eventBus.emit(TabIndexChanged(index: index)); + //首页 + if (index == 0) { + PreSaleProvider().refreshConfig(); //刷新预售权益 + // 从其它底栏切回主页时重拉「最新」等更新标记,避免长时间停留 App 内感知不到红点 + if (switchingToHome) { + HomeUpdateMarkerProvider().loadData(); + } + } + // 短视频播/停不用在这里管:切走后 IndexedStack 不再 paint 该页,其 RepaintBoundary 图层 + // 从图层树摘除,VisibilityDetector 收到 detach 回调报可见度 0,播放器自行暂停。 + // 动短视频页的 RepaintBoundary 前,务必回归「切到别的 tab 还有没有声音」 + //我的:切到该 tab 时整体刷新(用户信息/钱包/消息/预售/分层配置) + if (index == logic.tabs.length - 1 && Get.isRegistered()) { + Get.find().onRefresh(); + } + } + + @override + Widget build(BuildContext context) { + //选中态与内容页同源 curPageIndex:底栏点击、deeplink 外部跳转都只改它一处 + return Obx(() { + final cur = logic.curPageIndex.value; + return Container( + padding: EdgeInsets.fromLTRB(12, 0, 12, screen.paddingBottom), + decoration: BoxDecoration( + color: Colors.black, + border: Border( + top: BorderSide(color: Colors.black.withValues(alpha: 0.02))), + ), + child: Row( + children: List.generate(logic.tabs.length, (index) { + if (index == 0) + return _scrollTopItem(index, cur); + if (Config.darkWebEnable && index == 2) + return _scrollTopItem(index, cur); + if (index == logic.communityTab) + return _scrollTopItem(index, cur); + return _normalItem(index, cur); + }), + ), + ); + }); + } + + /// 选中后再点会变「回到顶部」的 tab(主页/暗网/社区) + Widget _scrollTopItem(int index, int cur) { + final isSelected = index == cur; + return Consumer(builder: (_, provider, __) { + final isTop = provider.showTop && isSelected; + final imagePath = + isTop ? 'jump_to_top.png'.homePath : _iconPath(index, isSelected); + //暗网配置Icon + final isDarkConfigImg = index == 2 && + isSelected && + Config.darkWebEnable && + Config.darkWebIcon?.isNotEmpty == true; + return Expanded( + child: InkWell( + enableFeedback: false, + onTap: () => isTop ? provider.scrollToTop() : _selectIndex(index), + child: _tabContent( + isSelected: isSelected, + text: isTop ? '顶部' : logic.tabs[index].title, + icon: isDarkConfigImg + ? NetworkImageLoader( + key: const ValueKey('darkIcon'), + imageUrl: Config.darkWebIcon) + : Image.asset(imagePath, key: ValueKey(imagePath), width: 24), + ), + ), + ); + }); + } + + /// 普通 tab + Widget _normalItem(int index, int cur) { + final isSelected = index == cur; + final imagePath = _iconPath(index, isSelected); + return Expanded( + child: InkWell( + enableFeedback: false, + onTap: () => _selectIndex(index), + child: _tabContent( + isSelected: isSelected, + text: logic.tabs[index].title, + icon: Image.asset(imagePath, key: ValueKey(imagePath), width: 24), + ), + ), + ); + } + + String _iconPath(int index, bool isSelected) => + isSelected ? logic.tabs[index].imageSel : logic.tabs[index].imageNor; + + /// 图标 + 文案:图标切换(key 变即图片路径变)交叉淡入,文字颜色平滑过渡 + Widget _tabContent( + {required Widget icon, required bool isSelected, required String text}) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 24, + alignment: Alignment.center, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), child: icon), + ), + 2.sizeBoxH, + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: TextStyle( + color: isSelected + ? const Color(0xffF68804) + : const Color(0xff656565), + fontSize: 10, + ), + child: Text(text), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/main_page/provider/bottom_bar_provider.dart b/lib/hj_page/main_page/provider/bottom_bar_provider.dart new file mode 100644 index 0000000..d74be53 --- /dev/null +++ b/lib/hj_page/main_page/provider/bottom_bar_provider.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/const.dart'; + +class BaseBottomProvider extends ChangeNotifier { + bool showTop = false; //选中的 tab 是否变成「回到顶部」 + + final _edge = 500.0; //滚动临界值 + final _ctrCaches = {}; + ScrollController? _curCtr; + + /// 绑定第 [index] 个 tab 的滚动控制器,[controller] 不传则复用缓存里的 + void setScrollController(int index, + {ScrollController? controller, bool forceToTop = false}) { + controller ??= _ctrCaches[index]; + if (controller == null) { + _curCtr?.removeListener(_onScroll); //没有可绑的目标,旧监听也一并摘掉 + return; + } + _ctrCaches[index] = controller; + showTop = !forceToTop && controller.hasClients && controller.offset > _edge; + _bind(controller); + } + + //换绑监听目标:摘旧挂新,notify 排到下一帧,避免在 build 期间通知 + void _bind(ScrollController ctr) { + _curCtr?.removeListener(_onScroll); + _curCtr = ctr; + ctr.addListener(_onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) => notifyListeners()); + } + + void _onScroll() { + final overEdge = (_curCtr?.offset ?? 0) > _edge; + if (overEdge != showTop) { + showTop = overEdge; + notifyListeners(); + } + } + + void scrollToTop() { + if (_curCtr?.hasClients == true) { + _curCtr?.jumpTo(0); + } + } +} + +class HomeBottomProvider extends BaseBottomProvider { + static final HomeBottomProvider _instance = HomeBottomProvider._(); + + factory HomeBottomProvider() => _instance; + + HomeBottomProvider._(); +} + +class DarkwebBottomProvider extends BaseBottomProvider { + static final DarkwebBottomProvider _instance = DarkwebBottomProvider._(); + + factory DarkwebBottomProvider() => _instance; + + DarkwebBottomProvider._(); +} + +class CommunityBottomProvider extends BaseBottomProvider { + static final CommunityBottomProvider _instance = CommunityBottomProvider._(); + + factory CommunityBottomProvider() => _instance; + + CommunityBottomProvider._(); + + //社区分区(社区/图集/黄游/小说) → 各 tab 的滚动控制器 + final _caches = >{}; + final _typeIndexes = {}; + + MediaStyle _type = MediaStyle.Community; + + /// 取某分区某 tab 的滚动控制器(没有就建并缓存),第一个 tab 顺带绑定监听 + ScrollController scrollCtr(MediaStyle type, int tabIndex) { + final ctr = (_caches[type] ??= {})[tabIndex] ??= ScrollController(); + if (tabIndex == 0) _bind(ctr); + return ctr; + } + + set communityType(MediaStyle t) { + _type = t; + _updateState(); + } + + set subIndex(int index) { + _typeIndexes[_type] = index; + _updateState(); + } + + void _updateState() { + final ctr = _caches[_type]?[_typeIndexes[_type] ?? 0]; + if (ctr == null) return; + showTop = ctr.hasClients && ctr.offset > _edge; + _bind(ctr); + } + + @Deprecated('请使用 scrollCtr / subIndex') + @override + void setScrollController(int index, + {ScrollController? controller, bool forceToTop = false}) {} +} diff --git a/lib/hj_page/main_page/provider/msg_provider.dart b/lib/hj_page/main_page/provider/msg_provider.dart new file mode 100644 index 0000000..d81070c --- /dev/null +++ b/lib/hj_page/main_page/provider/msg_provider.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/splash/domain_source_model.dart'; +import 'package:hgdj/hj_page/mine/mine_vip/vip_product_manager.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +import '../../../hj_utils/light_model.dart'; + +/// 我的未读消息,和分层优惠倒计时 +class MineMsgProvider with ChangeNotifier { + static final MineMsgProvider _instance = MineMsgProvider._(); + MineMsgProvider._(); + factory MineMsgProvider() => _instance; + + bool _hasNewMsg = false; //消息中心小红点 + + /// 优惠倒计时配置(全站唯一数据源),无配置/已过期时为 null + PayTierConfig? get countdownConfig { + final config = payTier?.config; + return config?.hasDiscountCountdown == true ? config : null; + } + + void loadData() { + getMessageTip(); + refreshPayPopup(); + } + + Future getMessageTip() async { + final res = await MineService.checkMessageTip(); + _hasNewMsg = res?.newsTip ?? false; + notifyListeners(); + } + + // ========== 支付状态分层(PayTier)========== + /// 支付状态分层配置(分层/卡ID/各展示位图片)——全站唯一数据源,原 Config.paymentStatusPopup 迁来此统一管理 + PayTierModel? payTier; + + static String get _payPopupShownKey => + "PayLayeredPopup_shown_${globalStore.meInfo?.uid}"; // 支付分层首弹是否已弹过 + final ValueNotifier tick = ValueNotifier( + 0); //分层倒计时秒级 tick,倒计时块用 ValueListenableBuilder 监听它,避免每秒重建整卡/图片 + Timer? _payTimer; //支付分层倒计时(基于 config.lastDiscountTime) + int? _lastExpireRefreshAt; //上次"归零刷新"的时间戳(秒),用于 30s 防抖 + + /// 刷新用户分层弹窗配置(payTier),成功后写回并通知 UI 刷新 + Future refreshPayPopup() async { + final model = await CommonService.refreshPayPopup( + keys: ['paymentPopup', 'paymentGuide']); + // 容错:接口返回空数据时 model 为 null,保留本地状态不替换 + if (model != null) { + // 分层状态前后不一致 → 用户权益/升级状态已变(VIP 支付是异步的,以分层变化为准),清会员卡缓存下次重开强制重拉 + final statusChanged = payTier?.status != model.status; + payTier = model; + if (statusChanged) { + vipProductManager.invalidate(); + } + startPayTimer(); //刷新后按新的截止时刻重启倒计时 + notifyListeners(); + } + return model; + } + + /// 启动支付分层倒计时:仅当优惠尚未截止才开,每秒自增 [tick] 驱动倒计时块刷新,过期自动停。 + /// 注意:倒计时块用 ValueListenableBuilder(MineMsgProvider().tick) 监听(只重建倒计时); + /// 整卡/横幅/浮窗的图片仍用 Consumer(配置刷新时才重建)。 + void startPayTimer() { + _payTimer?.cancel(); + _payTimer = null; + final config = payTier?.config; + if (config == null || !config.hasDiscountCountdown) return; + _payTimer = Timer.periodic(const Duration(seconds: 1), (_) { + // 过期:停表 + 收起倒计时,并拉一次最新分层(优惠结束后分层大概率已变) + if (!(payTier?.config?.hasDiscountCountdown ?? false)) { + _payTimer?.cancel(); + _payTimer = null; + _refreshOnExpire(); + } + tick.value++; + }); + } + + /// 支付分层首弹是否已弹过(不分新人/老人,全局只弹一次) + static Future isPayPopupShown() async => + await lightKV.getBool(_payPopupShownKey) ?? false; + + static Future markPayPopupShown() async => + await lightKV.setBool(_payPopupShownKey, true); + + /// 倒计时归零后刷新分层配置(带 30s 防抖,避免后端返回临界时间导致频繁刷新) + void _refreshOnExpire() { + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (_lastExpireRefreshAt != null && nowSec - _lastExpireRefreshAt! < 30) + return; + _lastExpireRefreshAt = nowSec; + refreshPayPopup(); // 成功后内部会按新 lastDiscountTime 重启/不重启计时 + } + + //红点view + Widget buildTipsView() { + return _hasNewMsg + ? Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Color(0xfff74f49), + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + ) + : SizedBox.shrink(); + } +} + +class NewMessageTip { + bool? newsTip; + + NewMessageTip({this.newsTip}); + + NewMessageTip.fromJson(dynamic json) { + newsTip = json['newsTip']; + } +} diff --git a/lib/hj_page/mine/collec_history_buy/col_his_buy_logic.dart b/lib/hj_page/mine/collec_history_buy/col_his_buy_logic.dart new file mode 100644 index 0000000..b49496f --- /dev/null +++ b/lib/hj_page/mine/collec_history_buy/col_his_buy_logic.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'col_his_buy_page.dart'; +import 'col_his_buy_sub_page.dart'; + +/// 收藏/购买/历史/喜欢共用:四种用途只差 tab 列表和能不能编辑 +class ColHisBuyLogic extends GetxController with GetSingleTickerProviderStateMixin { + final PageType type; + + ColHisBuyLogic(this.type); + + //喜欢没有小说 + late final List tabs = [ + LoadDataType.video, + LoadDataType.short, + LoadDataType.drama, + LoadDataType.post, + LoadDataType.comics, + LoadDataType.cartoon, + LoadDataType.pictures, + if (type != PageType.like) LoadDataType.novel, + ]; + + late final tabCtr = TabController(length: tabs.length, vsync: this); + + //编辑态:列表项右上角挂删除按钮 + bool isEdit = false; + + //已购没有删除接口,不给编辑入口 + bool get canEdit => type != PageType.buy; + + void toggleEdit() { + isEdit = !isEdit; + update(); + } + + @override + void onClose() { + tabCtr.dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/mine/collec_history_buy/col_his_buy_page.dart b/lib/hj_page/mine/collec_history_buy/col_his_buy_page.dart new file mode 100644 index 0000000..dde388b --- /dev/null +++ b/lib/hj_page/mine/collec_history_buy/col_his_buy_page.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../../../hj_utils/widget_util.dart'; +import 'col_his_buy_logic.dart'; +import 'col_his_buy_sub_page.dart'; + +/// 一个页面壳四种用途,每种钉一个独立 route。 +/// 这些名字没注册进 AppRoutes,只能走 [ColHisBuyPage.to],不能 Get.toNamed +enum PageType { + collect('我的收藏', '/MineCollectPage'), + buy('我的购买', '/MineBuyPage'), + history('历史记录', '/MineHistoryPage'), + like('我的喜欢', '/MineLikePage'); + + final String title; + final String route; + + const PageType(this.title, this.route); +} + +/// 收藏 / 购买 / 历史 / 喜欢 +class ColHisBuyPage extends StatelessWidget { + final PageType type; + + const ColHisBuyPage(this.type, {super.key}); + + static Future? to(PageType type) => + Get.to(() => ColHisBuyPage(type), routeName: type.route); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: ColHisBuyLogic(type), + builder: (logic) { + return Scaffold( + appBar: AppBar( + title: Text(type.title), + actions: [ + //已购没有删除接口,不给编辑入口 + if (logic.canEdit) + GestureDetector( + onTap: logic.toggleEdit, + child: Padding( + padding: EdgeInsets.only(right: 16), + child: Text( + logic.isEdit ? '完成' : '编辑', + style: textStyle(14, Color(0xff666666), FontWeight.w400), + ), + ), + ), + ], + ), + body: Column( + children: [ + //分类 tab + Container( + color: Get.theme.appBarTheme.backgroundColor, + child: TabBar( + labelPadding: EdgeInsets.zero, + padding: EdgeInsets.only(left: 8.w), + tabAlignment: TabAlignment.start, + indicator: CustomIndicator( + width: 16, + height: 3, + isGradient: true, + ), + indicatorWeight: 1, + unselectedLabelStyle: TextStyle( + color: Color(0xffcccccc), fontSize: 14, height: 1), + labelStyle: TextStyle( + color: Colors.white, + fontSize: 14, + height: 1, + fontWeight: FontWeight.w500, + ), + isScrollable: true, + tabs: logic.tabs + .map( + (e) => Padding( + padding: EdgeInsets.symmetric( + horizontal: 12.w, vertical: 8.h), + child: Text(e.title), + ), + ) + .toList(), + controller: logic.tabCtr, + ), + ), + //各分类列表 + Expanded( + child: TabBarView( + controller: logic.tabCtr, + children: logic.tabs + .map((e) => ColHisBuySubPage(e, + pageType: type, isEdit: logic.isEdit) + .keepAlive) + .toList(), + ), + ) + ], + ), + ); + }, + ); + } +} diff --git a/lib/hj_page/mine/collec_history_buy/col_his_buy_sub_logic.dart b/lib/hj_page/mine/collec_history_buy/col_his_buy_sub_logic.dart new file mode 100644 index 0000000..fe01fb3 --- /dev/null +++ b/lib/hj_page/mine/collec_history_buy/col_his_buy_sub_logic.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/cartoon_media_info.dart'; +import 'package:hgdj/hj_model/drama_media_info.dart'; +import 'package:hgdj/hj_model/list_base_model.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/acg_service.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/api_service/drama_service.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/history_util.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; +import 'package:hgdj/tools_base/cache/history/drama_resume_store.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; + +import '../../cartoon/acg_widget_item.dart'; +import '../../community/widget/community_post_widget.dart'; +import '../../community/widget/pic_simple_cell.dart'; +import '../../drama/drama_detail_page.dart'; +import '../../home/home_cell_style/tiktok_simple_cell.dart'; +import '../../home/home_cell_style/video_simple_cell.dart'; +import '../widgets/stage_cell_widget.dart'; +import 'col_his_buy_page.dart'; +import 'col_his_buy_sub_page.dart'; + +/// 用途 × 分类 决定用哪个 logic:短剧四种用途同一套 DramaInfo 列表,其余按用途分 +ColHisBuySubLogic instanceLogic(PageType pageType, LoadDataType loadType) { + if (loadType == LoadDataType.drama) return DramaLogic(loadType, pageType); + return switch (pageType) { + PageType.collect => CollectLogic(loadType), + PageType.buy => + loadType.isAcg ? BuyAcgLogic(loadType) : BuyVideoLogic(loadType), + PageType.history => HistoryLogic(loadType), + PageType.like => LikeLogic(loadType), + }; +} + +/// 收藏/购买/历史/喜欢的子列表基类:分页刷新和 item 渲染都在这,子类只管取数和删除 +abstract class ColHisBuySubLogic extends ListBaseLogic { + final LoadDataType loadType; + + ColHisBuySubLogic(this.loadType); + + @override + void onReady() { + super.onReady(); + loadData(); + } + + void loadData({bool isRefresh = true}) => + fetchData(isRefresh: isRefresh, fetch: fetch); + + /// 返回 (本页数据, 是否还有下一页),页码由基类算好传进来 + Future<(List?, bool)> fetch(int page); + + /// 本列表是否支持删除。false 时编辑态不挂删除按钮,免得点了没反应 + bool get canDelete => true; + + /// 取消收藏 / 删除一条,不支持的列表不用覆写 + Future deleteAt(int index) async {} + + Widget itemAt(int index) { + final model = dataList![index]; + return switch (loadType) { + LoadDataType.video => VideoSimpleCell(videoModel: model), + LoadDataType.game => + VideoSimpleCell(videoModel: model, isFromHY: true, textLines: 1), + LoadDataType.short => TiktokSimpleCell( + videoModel: model, + textLines: 1, + isShowBottom: true, + isShowTime: true), + LoadDataType.pictures => PicSimpleCell(videoModel: model, textLines: 1), + LoadDataType.post => CommunityPostWidget(videoModel: model), + LoadDataType.collections => SimpleCollectionsItem(model), + LoadDataType.comics || + LoadDataType.cartoon || + LoadDataType.novel => + AcgItemWidget(info: model), + //短剧卡片右下角挂集数状态,点进去续播 + LoadDataType.drama => VideoSimpleCell( + videoModel: model, + textLines: 1, + coverRightText: (model as VideoModel).dramaInfo?.episodeNumberStatus, + showLevelIcon: false, + onTap: () => _openDrama(model), + ), + }; + } + + /// 进短剧二级页,不指定集数(有续播位置就接着上次播)。 + /// 退回来重拉一次列表:历史按最后观看时间排,刚看的这部要跳到最前; + /// 收藏/喜欢那几个列表也可能在二级页里被改过状态 + Future _openDrama(VideoModel model) async { + await Get.to(() => DramaDetailPage(drama: model.dramaInfo)); + if (isClosed) return; // 二级页开着时用户退了本页 + loadData(); + } +} + +/// 短剧的收藏/已购/喜欢/历史:接口不同但返回的都是 DramaInfo 列表, +/// 统一转成 VideoModel 交给和热门短剧橱窗同一个卡片渲染 +class DramaLogic extends ColHisBuySubLogic { + final PageType pageType; + + DramaLogic(super.loadType, this.pageType); + + static const int _pageSize = 20; + + @override + Future<(List?, bool)> fetch(int page) async { + //历史记录只在本地:播放时写进 dramaResume 那张表,按最后观看时间倒序 + final list = pageType == PageType.history + ? (await dramaResume.history(page: page, pageSize: _pageSize)) + .map((e) => e.drama!) + .toList() + : (await _api(page))?.list ?? []; + //本地和接口都不看 hasNext,满页就当还有 + return ( + list.map((e) => e.toVideoModel(null)).toList(), + list.length >= _pageSize + ); + } + + Future?> _api(int page) => switch (pageType) { + PageType.buy => DramaService.fetchPurchased(page, size: _pageSize), + PageType.like => DramaService.fetchLikes(page, size: _pageSize), + _ => DramaService.fetchFavorites(page, size: _pageSize), + }; + + //已购没有删除接口,编辑态不给删除按钮;历史是本地表,能删 + @override + bool get canDelete => pageType != PageType.buy; + + @override + Future deleteAt(int index) async { + if (!canDelete) return; + final id = (dataList![index] as VideoModel).dramaInfo?.id ?? ''; + switch (pageType) { + case PageType.history: + await dramaResume.erase(id); + case PageType.collect: + await ACGService.deleteBookshelf(id); + default: + await CommonService.cancelLike(id, 'drama'); + } + dataList!.removeAt(index); + update(); + } +} + +/// 浏览历史,全部读本地库 +class HistoryLogic extends ColHisBuySubLogic { + HistoryLogic(super.loadType); + + static const int _pageSize = 20; + + @override + Future<(List?, bool)> fetch(int page) async { + final List list = loadType.isAcg + ? await HistoryUtil.fetch(loadType.historyType, + page: page, pageSize: _pageSize) + : await HistoryUtil.fetch(loadType.historyType, + page: page, pageSize: _pageSize); + //满页说明还有更多,不满即到底 + return (list, list.length >= _pageSize); + } + + @override + Future deleteAt(int index) async { + final model = dataList![index]; + if (model is! VideoModel && model is! CartoonMediaInfo) { + debugLog('删除类型未处理'); + return; + } + await HistoryUtil.delete(model, loadType.historyType); + dataList!.remove(model); + update(); + } +} + +/// 已购的漫画/动漫/小说 +class BuyAcgLogic extends ColHisBuySubLogic { + BuyAcgLogic(super.loadType); + + @override + Future<(List?, bool)> fetch(int page) async { + final res = await ACGService.fetchAcgBuyData(page, 12, + mediaType: loadType.apiType); + return (res?.list, res?.hasNext ?? false); + } +} + +/// 已购的影视/短视频/帖子/图集 +class BuyVideoLogic extends ColHisBuySubLogic { + BuyVideoLogic(super.loadType); + + @override + Future<(List?, bool)> fetch(int page) async { + final res = await MineService.fetchBuyVideo( + newsType: loadType.apiType, page: page, size: 12); + return (res?.list, res?.hasNext ?? false); + } +} + +/// 我的喜欢,取数和取消都是同一套 like 接口 +class LikeLogic extends ColHisBuySubLogic { + LikeLogic(super.loadType); + + @override + Future<(List?, bool)> fetch(int page) async { + final res = await MineService.fetchLikes( + likeType: loadType.apiType, page: page, size: 12); + return (res?.list, res?.hasNext ?? false); + } + + @override + Future deleteAt(int index) async { + await CommonService.cancelLike(dataList![index].id, loadType.apiType); + loadData(); + } +} + +/// 我的收藏,漫画/动漫/小说在书架接口,其余在收藏接口 +class CollectLogic extends ColHisBuySubLogic { + CollectLogic(super.loadType); + + @override + Future<(List?, bool)> fetch(int page) async { + final ListBaseModel? res = loadType.isAcg + ? await ACGService.fetchAcgBooklib(loadType.apiType, + page: page, size: 12) + : await MineService.fetchCollectList(loadType.apiType, + page: page, size: 12); + return (res?.list, res?.hasNext ?? false); + } + + @override + Future deleteAt(int index) async { + final model = dataList![index]; + final bool isDeleted; + if (model is VideoModel) { + isDeleted = + await MineService.postCollect(model.id, loadType.apiType, false); + } else if (model is CartoonMediaInfo) { + isDeleted = await ACGService.deleteBookshelf(model.id ?? ''); + } else { + debugLog('删除类型未处理'); + return; + } + if (!isDeleted) return; + dataList!.remove(model); + update(); + } +} diff --git a/lib/hj_page/mine/collec_history_buy/col_his_buy_sub_page.dart b/lib/hj_page/mine/collec_history_buy/col_his_buy_sub_page.dart new file mode 100644 index 0000000..f0aef37 --- /dev/null +++ b/lib/hj_page/mine/collec_history_buy/col_his_buy_sub_page.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/mine/collec_history_buy/col_his_buy_page.dart'; +import 'package:hgdj/hj_page/mine/collec_history_buy/col_his_buy_sub_logic.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +enum LoadDataType { + /// 长视频 + video( + title: '影视', + style: PageStyle.grid, + apiType: 'SP', + crossAxisCount: 2, + spaceV: 12, + spaceH: 8, + padding: EdgeInsets.only(left: 20, right: 20, top: 14), + ratio: 174 / 160, + historyType: MediaStyle.Video, + ), + + /// 短视频 + short( + title: '抖音', + style: PageStyle.grid, + apiType: 'SHORT', + crossAxisCount: 3, + spaceV: 12, + spaceH: 6, + padding: EdgeInsets.fromLTRB(16, 12, 16, 12), + ratio: 115 / 192, + historyType: MediaStyle.ShortVideo, + ), + + /// 短剧 media_bookshelf/list, media/my_buy, mine/like;历史走本地 DramaResumeStore + drama( + title: '短剧', + style: PageStyle.grid, + apiType: 'drama', + crossAxisCount: 2, + spaceV: 12, + spaceH: 6, + padding: EdgeInsets.only(left: 16, right: 16, top: 14), + ratio: 168 / 266, + historyType: MediaStyle.Drama, + ), + + /// 帖子 + post( + title: '帖子', + style: PageStyle.list, + apiType: 'COVER', + crossAxisCount: 2, + spaceV: 10, + spaceH: 14, + padding: EdgeInsets.only(left: 0, right: 0, top: 14), + ratio: 191 / 174, + historyType: MediaStyle.Community, + ), + + /// 漫画 media_bookshelf/list, media/my_buy + comics( + title: '漫画', + style: PageStyle.grid, + apiType: 'image', + crossAxisCount: 3, + spaceV: 12, + spaceH: 6, + padding: EdgeInsets.only(left: 16, right: 16, top: 14), + ratio: 115 / 192, + historyType: MediaStyle.Comics, + ), + + /// 动漫 media_bookshelf/list, media/my_buy + cartoon( + title: '动漫', + style: PageStyle.grid, + apiType: 'video', + crossAxisCount: 3, + spaceV: 12, + spaceH: 6, + padding: EdgeInsets.only(left: 16, right: 16, top: 14), + ratio: 115 / 192, + historyType: MediaStyle.Cartoon, + ), + + /// 图集 + pictures( + title: '图集', + style: PageStyle.grid, + apiType: 'PIC', + crossAxisCount: 3, + spaceV: 12, + spaceH: 6, + padding: EdgeInsets.fromLTRB(16, 12, 16, 12), + ratio: 115 / 192, + historyType: MediaStyle.Pic, + ), + + /// 小说 media_bookshelf/list, media/my_buy + novel( + title: '小说', + style: PageStyle.grid, + apiType: 'text', + crossAxisCount: 3, + spaceV: 12, + spaceH: 6, + padding: EdgeInsets.only(left: 16, right: 16, top: 14), + ratio: 115 / 192, + historyType: MediaStyle.Novel, + ), + + /// 种子/黄油帖子(tab 暂时关掉,配置留着) + game( + title: '种子', + style: PageStyle.grid, + apiType: 'game', + crossAxisCount: 2, + spaceV: 12, + spaceH: 8, + padding: EdgeInsets.only(left: 20, right: 20, top: 14), + ratio: 174 / 160, + historyType: MediaStyle.Game, + ), + + /// 合集(tab 暂时关掉,配置留着) + collections( + title: '合集', + style: PageStyle.list, + apiType: 'collection', + crossAxisCount: 2, + spaceV: 12, + spaceH: 6, + padding: EdgeInsets.only(left: 16, right: 16, top: 14), + ratio: 115 / 175, + historyType: MediaStyle.Cartoon, + ); + + /// tab 标题 + final String title; + + /// 页面风格 列表、网格 + final PageStyle style; + + /// 接口的类型参数(收藏 type / 已购 mediaType / 喜欢 likeType 是同一个值) + final String apiType; + + /// 针对网格 纵向多少个 + final int crossAxisCount; + + /// 网格 垂直间隔 + final double spaceV; + + /// 网格 横向间隔 + final double spaceH; + + /// 页面间距 + final EdgeInsets padding; + + /// 网格宽高比 + final double ratio; + + /// 历史记录类型 + final MediaStyle historyType; + + const LoadDataType({ + required this.title, + required this.style, + required this.apiType, + required this.crossAxisCount, + required this.spaceV, + required this.spaceH, + required this.padding, + required this.ratio, + required this.historyType, + }); + + /// 漫画/动漫/小说走 ACG 那套接口,返回 CartoonMediaInfo;其余走 VideoModel + bool get isAcg => this == comics || this == cartoon || this == novel; +} + +enum PageStyle { + list, + grid; +} + +class ColHisBuySubPage extends StatelessWidget { + final LoadDataType loadType; + final PageType pageType; + final bool isEdit; + + const ColHisBuySubPage( + this.loadType, { + super.key, + required this.pageType, + this.isEdit = false, + }); + + //一个页面里 8 个 tab 的 logic 同时活着,按用途+分类隔离; + //用 .name 而非 toString(),它是编译期常量,release 混淆不会改 + String get _tag => '${pageType.name}_${loadType.name}'; + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: _tag, + init: instanceLogic(pageType, loadType), + builder: (logic) { + return pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onRefresh: (_) => logic.loadData(), + onLoading: (_) => logic.loadData(isRefresh: false), + child: () { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.isEmptyData) return CErrorWidget(); + return _list(logic); + }(), + ); + }, + ); + } + + Widget _list(ColHisBuySubLogic logic) { + final count = logic.dataList!.length; + if (loadType.style == PageStyle.list) { + return ListView.separated( + separatorBuilder: (_, __) => 14.sizeBoxH, + itemCount: count, + padding: loadType.padding, + itemBuilder: (_, index) => _item(logic, index, StackFit.loose), + ); + } + return GridView.builder( + padding: loadType.padding, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: loadType.crossAxisCount, + mainAxisSpacing: loadType.spaceV, + crossAxisSpacing: loadType.spaceH, + childAspectRatio: loadType.ratio, + ), + itemCount: count, + itemBuilder: (_, index) => _item(logic, index, StackFit.expand), + ); + } + + //列表项 + 编辑态右上角删除按钮 + Widget _item(ColHisBuySubLogic logic, int index, StackFit fit) { + return Stack( + fit: fit, + children: [ + logic.itemAt(index), + if (isEdit && logic.canDelete) + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () => logic.deleteAt(index), + child: Image.asset('mine_col_delete.png'.mineImgPath, width: 20), + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/mine/home_mine_logic.dart b/lib/hj_page/mine/home_mine_logic.dart new file mode 100644 index 0000000..68fb614 --- /dev/null +++ b/lib/hj_page/mine/home_mine_logic.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/hj_utils/light_model.dart'; +import 'package:hgdj/hj_utils/store_keys.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_helper.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; +import 'package:hgdj/track_event_manager/device_service.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../config/config.dart'; +import '../../hj_model/user/user_info_model.dart'; +import '../../routers/jump_router.dart'; +import '../main_page/provider/msg_provider.dart'; +import '../web_page/h5_page.dart'; +import 'collec_history_buy/col_his_buy_page.dart'; +import 'identity/mine_identity_page.dart'; +import 'message/message_center_page.dart'; +import 'mine_drive/mine_drive_page.dart'; +import 'mine_following/mine_following_view.dart'; +import 'mine_post/mine_publish_main_page.dart'; +import 'mine_setting/mine_code_page.dart'; +import 'mine_setting/mine_password_logic.dart'; +import 'mine_setting/mine_password_page.dart'; +import 'mine_setting/mine_setting_page.dart'; +import 'mine_share/mine_share_page.dart'; +import 'more_question/mine_feedback_page.dart'; +import 'welfare/welfare_home_page.dart'; + +//图标 +class IconBean { + String image; + String name; + + IconBean(this.image, this.name); +} + +class MineMainLogic extends GetxController with GetTickerProviderStateMixin { + RefreshController? refreshCtr; + + UserInfoModel? get userInfo => globalStore.meInfo; + + List get menuVerNameArr => [ + "意见反馈", + "我的购买", + "我的喜欢", + "分享邀请", + "应用推荐", + "账号凭证", + "锁屏密码", + "我的消息", + ]; + + List iconList = [ + IconBean('ic_launcher.webp'.commonImgPath, Config.appName), + IconBean('icon_1.webp'.appImgPath, "爱音乐"), + IconBean('icon_2.webp'.appImgPath, "爱阅读"), + IconBean('icon_3.webp'.appImgPath, "支付宝"), + IconBean('icon_4.webp'.appImgPath, "哔哩哔哩"), + IconBean('icon_5.webp'.appImgPath, "饼干大作战"), + IconBean('icon_6.webp'.appImgPath, "美食借鉴"), + IconBean('icon_7.webp'.appImgPath, "我的世界"), + IconBean('icon_8.webp'.appImgPath, "神选之战"), + IconBean('icon_9.webp'.appImgPath, "随心无线"), + IconBean('icon_10.webp'.appImgPath, "特轩小说"), + IconBean('icon_11.webp'.appImgPath, "天天麻将"), + IconBean('icon_12.webp'.appImgPath, "途游"), + IconBean('icon_13.webp'.appImgPath, "微信"), + IconBean('icon_14.webp'.appImgPath, "问道"), + IconBean('icon_15.webp'.appImgPath, "英语图书角"), + ]; + + @override + void onReady() async { + super.onReady(); + bool savedQR = await lightKV.getBool(StoreKeys.HAVE_SAVE_QR_CODE) ?? false; + if (!savedQR) { + lightKV.setBool(StoreKeys.HAVE_SAVE_QR_CODE, true); + await _showSaveQrDialog(); + } + + onRefresh(); + } + + Future getUserInfo() async { + await globalStore.updateUserInfo(); + } + + Future getWallet() async { + await globalStore.refreshWallet(); + } + + Future onRefresh() async { + await Future.wait([ + getUserInfo(), + MineMsgProvider().getMessageTip(), + PreSaleProvider().refreshAll(), //刷新预售 + MineMsgProvider().refreshPayPopup(), //刷新支付分层配置(状态变则清会员卡缓存 + 重启倒计时) + getWallet(), + ]).whenComplete(() { + refreshCtr?.refreshCompleted(); + update(); + }); + } + + /// 首次进入二维码页面提示保存二维码 + Future _showSaveQrDialog() async { + final value = await Get.dialog(MineAccountIdentityPage()); + if (value != null && value is UserInfoModel) { + update(); + } + return value; + } + + void menuTapEvent(int index) async { + if (index == 0) { + //我的帖子 + Get.to(MinePublishPostPage(), preventDuplicates: false); + } else if (index == 1) { + //我的喜欢 + ColHisBuyPage.to(PageType.like); + } else if (index == 2) { + //我的关注 + Get.to(() => MineFollowingPage()); + } else if (index == 3) { + //消息中心 + await Get.to(() => MessageCenterPage(), + opaque: true, preventDuplicates: false); + MineMsgProvider().getMessageTip(); + } + } + + void menuVerTapEvent(int index) async { + if (index == 0) { + //创作中心 + Get.to(MinePublishPostPage(), preventDuplicates: false); + } else if (index == 1) { + //意见反馈 + Get.to(() => const MineFeedbackPage()); + } else if (index == 2) { + //我的购买 + ColHisBuyPage.to(PageType.buy); + } else if (index == 3) { + //我的喜欢 + ColHisBuyPage.to(PageType.like); + } else if (index == 4) { + //分享邀请 + Get.to(() => MineSharePage()); + } else if (index == 5) { + //领取兑换 + Get.to(() => MineExchangeCodePage(type: 1)); + } else if (index == 6) { + //加群开车 + Get.to(() => MineDrivePage()); + } else if (index == 7) { + //应用推荐 + Get.to(() => const WelfareHomePage(index: 1), preventDuplicates: false); + } else if (index == 8) { + //账号凭证 + await Get.dialog(MineAccountIdentityPage()); + } else if (index == 9) { + //锁屏密码 + if (globalStore.password.isNotEmpty) { + final close = await CommonAlert.show( + title: '关闭锁定码', + content: '您确定关闭锁定密码?\n\n开启需重新输入密码', + cancelText: '稍后再说', + confirmText: '确定关闭', + ); + if (close) { + Get.toNamed(MinePasswordPage.routeName, + arguments: MinePasswordType.close); + } + } else { + Get.toNamed( + MinePasswordPage.routeName, + arguments: MinePasswordType.setting, + ); + } + } else if (index == 10) { + menuTapEvent(3); + } else if (index == 11) { + pushToCustomService(); + } + } + + toSetting() { + Get.to(() => const MineSettingPage()); + } + + toMineShare() { + Get.to(() => MineSharePage()); + } + + toExchangeCenter() { + Get.to(() => const WelfareHomePage(index: 1)); + } + + toShop() async { + LoadingHelper.showLoading(); + var result = await VidService.getShopAddress(); + LoadingHelper.dismissLoading(); + if (result is String && result.isNotEmpty != true) { + showToast("获取商店地址失败~"); + return; + } + Get.to(H5Page(url: result, showTitle: false, showClose: true), + popGesture: true); + } + + changeIcon(int index) async { + if (!await CommonAlert.show(content: '您确定要更换图标吗?')) return; + const platform = MethodChannel(DeviceInfoService.requestChannel); + platform.invokeMethod('changeIcon', {"iconIndex": index}); + } +} diff --git a/lib/hj_page/mine/home_mine_page.dart b/lib/hj_page/mine/home_mine_page.dart new file mode 100644 index 0000000..b181718 --- /dev/null +++ b/lib/hj_page/mine/home_mine_page.dart @@ -0,0 +1,353 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/address.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../hj_utils/widget_util.dart'; +import '../../tools_base/banner/ads_grid_view_widget.dart'; +import 'home_mine_logic.dart'; +import 'widgets/mine_info_widget.dart'; +import 'widgets/vip_entry_card.dart'; + +//我的页面 +class HomeMinePage extends StatelessWidget { + const HomeMinePage({super.key}); + + /// 常用功能里唯一需要展示未读红点的入口 + static const _messageMenuTitle = '我的消息'; + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MineMainLogic(), + builder: (logic) { + return Consumer(builder: (_, store, ___) { + return Scaffold( + body: SizedBox.expand( + child: pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + enablePullUp: false, + onRefresh: (ctr) => logic.onRefresh(), + child: SingleChildScrollView( + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + child: _buildBg(), + ), + SafeArea(child: _buildContent(logic, store)), + ], + ), + ), + ), + ), + ); + }); + }, + ); + } + + //顶部背景:优先后台下发,兜底默认图 + Widget _buildBg() { + final bg = Config.mineBg; + if (bg != null && bg.isNotEmpty) { + return NetworkImageLoader(imageUrl: bg, borderRadius: 0); + } + return Image.asset( + 'mine_bg.webp'.mineImgPath, + ); + } + + Widget _buildContent(MineMainLogic logic, GlobalStore store) { + return Stack( + children: [ + _buildTop(logic), + Column( + children: [ + 48.sizeBoxH, + MineInfoWidget(logic: logic), + 21.sizeBoxH, + _buildCountView(), //次数 + 21.sizeBoxH, + VipEntryCard(), //会员入口 + 12.sizeBoxH, + MineTaskMenuWidget(), //金币充值等 + 12.sizeBoxH, + MineFunchtionGridView(), //收藏/历史/下载/关注 + 12.sizeBoxH, + _buildShop(logic), //商店入口 + AdsGridViewWidget( + 8, + accordingAdsType: true, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + Container( + margin: EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: Colors.white.withValues(alpha: .05), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + alignment: Alignment.centerLeft, + margin: EdgeInsets.symmetric(horizontal: 16, vertical: 5), + child: Text("常用功能", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 16, + )), + ), + GridView.builder( + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: logic.menuVerNameArr.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + childAspectRatio: 1, + ), + itemBuilder: (context, index) { + String titleDesc = logic.menuVerNameArr[index]; + String imagePath = + 'mine_menu_${index + 1}.png'.mineImgPath; + // 仅「我的消息」带未读红点,按名称判断避免菜单顺序变动后红点错位 + final isMessageEntry = titleDesc == _messageMenuTitle; + return InkWell( + enableFeedback: false, + onTap: () => logic.menuVerTapEvent(index), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + !isMessageEntry + ? Image.asset(imagePath, width: 28) + : Stack( + children: [ + Image.asset( + "mine_notification.png".mineImgPath, + width: 28, + ), + Consumer( + builder: (context, model, child) => + Positioned( + right: 2, + top: 2, + child: model.buildTipsView(), + ), + ) + ], + ), + 5.sizeBoxH, + Text( + titleDesc, + style: + TextStyle(color: Colors.white, fontSize: 12), + ), + ], + ), + ); + }, + ), + // iOS 换图标需弹系统弹窗且要预置图标资源,暂不支持,仅 Android 显示该入口 + if (!Platform.isIOS) ...[ + Padding( + padding: EdgeInsets.only(left: 12, top: 10, right: 12), + child: Row( + children: [ + Text( + '设置桌面图标', + style: TextStyle( + color: Color(0xffEFEFEF), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + Spacer(), + InkWell( + enableFeedback: false, + onTap: () => logic.changeIcon(0), + child: Text( + '恢复默认', + style: TextStyle( + color: Color(0xff989898), + fontSize: 12, + fontWeight: FontWeight.w400), + ), + ), + ], + ), + ), + 11.sizeBoxH, + SizedBox( + height: 76, + child: ListView.separated( + padding: EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + separatorBuilder: (_, __) => 7.sizeBoxW, + itemCount: logic.iconList.length, + itemBuilder: (BuildContext context, int index) { + final data = logic.iconList[index]; + return InkWell( + enableFeedback: false, + onTap: () => logic.changeIcon(index), + child: Column( + children: [ + Image.asset(data.image, width: 54), + 4.sizeBoxH, + Text( + data.name, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 12, + ), + ) + ], + ), + ); + }, + ), + ), + 10.sizeBoxH, + ], + ], + ), + ), + 12.sizeBoxH, + Text( + '我的邀请码: ${store.meInfo?.promotionCode ?? ''}', + style: textStyle(16, Color(0xffBDBDBD), FontWeight.w400), + ), + 6.sizeBoxH, + _buildGroundUrl(), + 30.sizeBoxH, + ], + ), + ], + ); + } + + //永久官方地址:地址未下发时不渲染,避免点击/复制空串 + Widget _buildGroundUrl() { + final url = Address.groundUrl; + if (url == null || url.isEmpty) return const SizedBox.shrink(); + return GestureDetector( + onTap: () => launchUrlToWeb(url), + onLongPress: () { + Clipboard.setData(ClipboardData(text: url)); + showToast("复制成功"); + }, + child: Text( + '永久官方地址: $url', + style: textStyle(12, Color(0xff525252), FontWeight.w400), + ), + ); + } + + //消息和设置 + Widget _buildTop(MineMainLogic logic) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + InkWell( + enableFeedback: false, + onTap: () => logic.menuTapEvent(3), + child: Image.asset("mine_notification.png".mineImgPath, width: 24), + ), + Spacer(), + InkWell( + enableFeedback: false, + onTap: () => logic.toSetting(), + child: Image.asset("setting.png".mineImgPath, width: 24), + ), + ], + ), + ); + } + + //次数 + Widget _buildCountView() { + return Row( + children: [ + 50.sizeBoxW, + Expanded( + child: Consumer2( + builder: (context, store, provider, child) { + int count = (store.wallet?.downloadCount ?? 0) + + (provider.remain?.todayDownloadCount ?? 0); + return _buildCountItem(count.toString(), '剩余缓存次数'); + }, + ), + ), + Container( + color: Colors.white.withValues(alpha: .1), + width: 0.5, + height: 12, + ), + Expanded( + child: Consumer2( + builder: (context, store, provider, child) { + //预售权益和免费次数的和 + final total = (store.wallet?.aiUndressFreeTimes ?? 0) + + (provider.remain?.todayAiUndressCount ?? 0); + return _buildCountItem(total.toString(), '剩余AI脱衣次数'); + }, + ), + ), + 50.sizeBoxW, + ], + ); + } + + Widget _buildCountItem(String count, String title) { + return Column( + children: [ + Text( + count, + style: textStyle(16, Colors.white, FontWeight.w500), + ), + 4.sizeBoxH, + Text( + title, + style: textStyle( + 12, Colors.white.withValues(alpha: .7), FontWeight.w400), + ), + ], + ); + } + + Widget _buildShop(MineMainLogic logic) { + if (Config.isStoreOpen) { + return InkWell( + enableFeedback: false, + onTap: () => logic.toShop(), + child: Padding( + padding: const EdgeInsets.only(bottom: 10.0, left: 10, right: 10), + child: AspectRatio( + aspectRatio: 355 / 82, + child: Image.asset('mine_shop_icon.webp'.mineImgPath), + ), + ), + ); + } + return SizedBox.shrink(); + } +} diff --git a/lib/hj_page/mine/identity/mine_identity_logic.dart b/lib/hj_page/mine/identity/mine_identity_logic.dart new file mode 100644 index 0000000..5094f5c --- /dev/null +++ b/lib/hj_page/mine/identity/mine_identity_logic.dart @@ -0,0 +1,30 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +import '../../../hj_model/user/user_info_model.dart'; + +class MineIdentityLogic extends GetxController { + MineIdentityLogic get to => Get.find(); + + UserInfoModel? meInfo = globalStore.meInfo; + + GlobalKey boundaryKey = GlobalKey(); + String qrCodeStr = ''; + + int? inviterNum; + + @override + void onReady() { + super.onReady(); + loadData(); + } + + void loadData() async { + globalStore.updateUserInfo(); + qrCodeStr = await MineService.certificateQR() ?? ''; + update(); + } +} diff --git a/lib/hj_page/mine/identity/mine_identity_page.dart b/lib/hj_page/mine/identity/mine_identity_page.dart new file mode 100644 index 0000000..7470be5 --- /dev/null +++ b/lib/hj_page/mine/identity/mine_identity_page.dart @@ -0,0 +1,227 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/address.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/image_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../../../tools_base/global_store/store.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import 'mine_identity_logic.dart'; + +//账号凭证页面 +class MineAccountIdentityPage extends StatelessWidget { + const MineAccountIdentityPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MineIdentityLogic(), + builder: (controller) { + return Material( + color: Colors.transparent, + child: Container( + alignment: Alignment.center, + width: double.infinity, + height: double.infinity, + child: Center( + child: SingleChildScrollView( + child: SizedBox( + width: 300, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + RepaintBoundary( + key: controller.boundaryKey, + child: Stack( + alignment: Alignment.topCenter, + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(9), + child: Image.asset('share_bg.webp'.mineImgPath, + fit: BoxFit.fill), + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + 30.sizeBoxH, + Consumer( + builder: (_, provider, __) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + border: Border.all( + color: Color(0x4DF68804), width: 3), + ), + child: NetworkImageLoader( + imageUrl: + provider.meInfo?.portrait ?? '', + width: 82, + height: 82, + borderRadius: 50, + ), + ); + }, + ), + 10.sizeBoxH, + Text( + '账号凭证', + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + 10.sizeBoxH, + Text( + 'ID ${globalStore.meInfo?.uid ?? ""}', + style: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + 30.sizeBoxH, + Text("提示*可通过官方客服找回账号", + style: TextStyle( + fontSize: 12, + color: Color(0xFFF68804))), + 10.sizeBoxH, + Center( + child: Container( + padding: EdgeInsets.all(11.w), + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage( + "code_bg.webp".mineImgPath), + fit: BoxFit.fill, + ), + ), + child: Container( + padding: EdgeInsets.all(6.w), + color: Colors.white, + child: controller.qrCodeStr.isNotEmpty + ? QrImageView( + data: controller.qrCodeStr, + version: QrVersions.auto, + padding: EdgeInsets.zero, + size: 100, + ) + : SizedBox( + width: 100, + height: 100, + child: Center( + child: + CupertinoActivityIndicator( + color: AppColors.actionRed, + ), + ), + )), + ), + ), + 18.h.sizeBoxH, + EasyRichText( + "APP更新可能会导致会员账号掉线\n请勿删除此凭证,此凭证是您永久有\n效的登陆途径。", + textAlign: TextAlign.center, + defaultStyle: TextStyle( + color: + Colors.white.withValues(alpha: .55), + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1.5), + patternList: [ + EasyRichTextPattern( + targetString: '请勿删除此凭证', + style: TextStyle( + color: Color(0xFFF52C56), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + Container( + height: 30, + padding: EdgeInsets.symmetric( + horizontal: 18, vertical: 3), + child: Text( + '${Config.appName} 官网地址 ${Address.groundUrl ?? ""}', + style: TextStyle( + color: + Colors.white.withValues(alpha: .55), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ), + 20.sizeBoxH, + ], + ), + ], + ), + ), + 10.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () async { + var success = await ImageUtil.saveWidgetToAlbum( + controller.boundaryKey); + if (success) { + showToast("保存成功"); + Get.back(); + } + }, + child: Container( + width: 220, + height: 44, + padding: EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(22), + ), + alignment: Alignment.center, + child: Text( + "立即保存", + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + 20.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () => Get.back(result: false), + child: Image.asset("close_button.png".commonImgPath, + width: 32), + ) + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/hj_page/mine/make_money/alipay_ccdcapi_model.dart b/lib/hj_page/mine/make_money/alipay_ccdcapi_model.dart new file mode 100644 index 0000000..a48aac4 --- /dev/null +++ b/lib/hj_page/mine/make_money/alipay_ccdcapi_model.dart @@ -0,0 +1,29 @@ +class ApcApiModel { + String? bank; + bool? validated; + String? cardType; + String? key; + List? messages; + String? stat; + + static ApcApiModel? fromMap(Map? map) { + if (map == null) return null; + ApcApiModel apcApiModel = ApcApiModel(); + apcApiModel.bank = map['bank']; + apcApiModel.validated = map['validated']; + apcApiModel.cardType = map['cardType']; + apcApiModel.key = map['key']; + apcApiModel.messages = map['messages']; + apcApiModel.stat = map['stat']; + return apcApiModel; + } + + Map toJson() => { + "bank": bank, + "validated": validated, + "cardType": cardType, + "key": key, + "messages": messages, + "stat": stat, + }; +} diff --git a/lib/hj_page/mine/make_money/in_come_entity.dart b/lib/hj_page/mine/make_money/in_come_entity.dart new file mode 100644 index 0000000..5e9c2c0 --- /dev/null +++ b/lib/hj_page/mine/make_money/in_come_entity.dart @@ -0,0 +1,123 @@ +class WithdrawConfig { + WithdrawConfig({ + this.channels, + this.id, + this.cashTax, + this.coinTax, + this.gameTax, + }); + + List? channels; + int? id; + int? cashTax; + int? coinTax; + int? gameTax; + + Channel? get getBankCardChannel { + for (Channel item in (channels ?? [])) { + if (item.isBankCard) { + return item; + } + } + return null; + } + + List get realChannel { + final channel_ = []; + + if (getBankCardChannel != null) { + channel_.add(getBankCardChannel!); + } + + if (getUsdtChannel != null) { + channel_.add(getUsdtChannel!); + } + + return channel_; + } + + Channel? get getUsdtChannel { + for (Channel item in (channels ?? [])) { + if (item.isUsdt) { + return item; + } + } + return null; + } + + bool get hasBankCard { + for (Channel item in (channels ?? [])) { + if (item.isBankCard) { + return true; + } + } + return false; + } + + bool get hasUsdt { + for (Channel item in (channels ?? [])) { + if (item.isUsdt) { + return true; + } + } + return false; + } + + factory WithdrawConfig.fromJson(Map json) => WithdrawConfig( + channels: List.from(json["channels"].map((x) => Channel.fromJson(x))), + id: json["ID"], + cashTax: json["cashTax"], + coinTax: json["coinTax"], + gameTax: json["gameTax"], + ); + + Map toJson() => { + "channels": channels == null ? [] : List.from(channels!.map((x) => x.toJson())), + "ID": id, + "cashTax": cashTax, + "coinTax": coinTax, + "gameTax": gameTax, + }; +} + +class Channel { + Channel({ + this.channelName, + this.cid, + this.payType, + this.minMoney, + this.maxMoney, + this.qpMinMoney, + this.qpMaxMoney, + }); + + String? channelName; + String? cid; + String? payType; + int? minMoney; + int? maxMoney; + int? qpMinMoney; + int? qpMaxMoney; + bool get isBankCard => payType?.toLowerCase() == "bankcard"; + bool get isUsdt => payType?.toLowerCase() == "usdt"; + + factory Channel.fromJson(Map json) => Channel( + channelName: json["channelName"], + cid: json["cid"], + payType: json["payType"], + minMoney: json["minMoney"], + maxMoney: json["maxMoney"], + qpMinMoney: json["qpMinMoney"], + qpMaxMoney: json["qpMaxMoney"], + ); + + Map toJson() => { + "channelName": channelName, + "cid": cid, + "payType": payType, + "minMoney": minMoney, + "maxMoney": maxMoney, + "qpMinMoney": qpMinMoney, + "qpMaxMoney": qpMaxMoney, + }; +} diff --git a/lib/hj_page/mine/make_money/mine_withdrawal_record_logic.dart b/lib/hj_page/mine/make_money/mine_withdrawal_record_logic.dart new file mode 100644 index 0000000..95c30f3 --- /dev/null +++ b/lib/hj_page/mine/make_money/mine_withdrawal_record_logic.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import 'mine_withdrawal_record_page.dart'; +import 'widget/record_list_item.dart'; +import 'withdraw_details_model.dart'; + +abstract class MineWithdrawalRecordLogic extends GetxController { + RefreshController? refreshController; + int page = 1; + + WithdrawDetailsModel? withdrawDetailsModel; + final dataSource = []; + bool isLoading = true; + + @override + onReady() { + super.onReady(); + fetchPageData(); + } + + @mustCallSuper + fetchPageData({bool isRefresh = true}) async { + if (isRefresh) page = 1; + } + + Widget instanceChildItem(int index); +} + +//提现明细 +class WithDrawalRecordController extends MineWithdrawalRecordLogic { + @override + fetchPageData({bool isRefresh = true}) async { + super.fetchPageData(isRefresh: isRefresh); + + final result = + await MineService.getWithdrawDetails(pageNumber: page, pageSize: 10); + isLoading = false; + + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + result?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(result?.list ?? []); + page += 1; + update(); + } + + @override + Widget instanceChildItem(int index) { + return WithdrawalRecordItem(dataSource[index]); + } +} + +//金币订单明细 +class GoldBillRecordController extends MineWithdrawalRecordLogic { + @override + fetchPageData({bool isRefresh = true}) async { + super.fetchPageData(isRefresh: isRefresh); + final res = await MineService.getBillData( + pageSize: 15, + pageNumber: page, + type: 1, + ); + isLoading = false; + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + res?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(res?.list ?? []); + page += 1; + update(); + } + + @override + Widget instanceChildItem(int index) { + return GoldRecordItem(dataSource[index]); + } +} + +//充值明细 +class RechargeRecordController extends MineWithdrawalRecordLogic { + @override + fetchPageData({bool isRefresh = true}) async { + super.fetchPageData(isRefresh: isRefresh); + final res = + await MineService.getRechargeBill(pageNumber: page, pageSize: 15); + isLoading = false; + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + res?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(res?.list ?? []); + page += 1; + update(); + } + + @override + Widget instanceChildItem(int index) { + return Padding( + padding: EdgeInsets.only(bottom: 14), + child: RechargeRecordItem(dataSource[index]), + ); + } +} + +//收益明细 +class IncomeRecordController extends MineWithdrawalRecordLogic { + @override + fetchPageData({bool isRefresh = true}) async { + super.fetchPageData(isRefresh: isRefresh); + final res = + await MineService.getIncomeRecord(pageNumber: page, pageSize: 15); + isLoading = false; + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + res?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(res?.list ?? []); + page += 1; + update(); + } + + @override + Widget instanceChildItem(int index) { + return Padding( + padding: EdgeInsets.only(bottom: 12), + child: InComeRecordItem(dataSource[index]), + ); + } +} + +MineWithdrawalRecordLogic instanceController(RecordType type) { + switch (type) { + case RecordType.bill: + return GoldBillRecordController(); + case RecordType.withdraw: + return WithDrawalRecordController(); + case RecordType.recharge: + return RechargeRecordController(); + case RecordType.income: + return IncomeRecordController(); + default: + throw '$type 没有找到'; + } +} diff --git a/lib/hj_page/mine/make_money/mine_withdrawal_record_page.dart b/lib/hj_page/mine/make_money/mine_withdrawal_record_page.dart new file mode 100644 index 0000000..4d14b72 --- /dev/null +++ b/lib/hj_page/mine/make_money/mine_withdrawal_record_page.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import 'mine_withdrawal_record_logic.dart'; + +enum RecordType { + recharge('充值记录'), + withdraw('提现明细'), + bill('余额明细'), + income('业绩明细'); + + final String title; + const RecordType(this.title); +} + +//明细综合页面 +class RecordsPage extends StatefulWidget { + final RecordType type; // 0:收益, 1: 提现 + const RecordsPage(this.type, {super.key}); + + @override + State createState() => _RecordsPageState(); +} + +class _RecordsPageState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: instanceController(widget.type), + builder: (controller) { + return Scaffold( + appBar: AppBar(title: Text(widget.type.title)), + body: pullYsRefresh( + onRefresh: (refreshController) => controller.fetchPageData(), + onLoading: (refreshController) => + controller.fetchPageData(isRefresh: false), + onInit: (ctr) => controller.refreshController = ctr, + child: () { + if (controller.isLoading) return LoadingCenterWidget(); + if (controller.dataSource.isEmpty) + return CErrorWidget( + retryOnTap: () => controller.fetchPageData()); + + return ListView.builder( + itemCount: controller.dataSource.length, + padding: EdgeInsets.only(top: 12, left: 16.w, right: 16.w), + itemBuilder: (context, index) => + controller.instanceChildItem(index), + ); + }(), + ), + ); + }, + ); + } +} diff --git a/lib/hj_page/mine/make_money/widget/record_list_item.dart b/lib/hj_page/mine/make_money/widget/record_list_item.dart new file mode 100644 index 0000000..808e044 --- /dev/null +++ b/lib/hj_page/mine/make_money/widget/record_list_item.dart @@ -0,0 +1,364 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/mine/exchange/bill_item_model.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../withdraw_details_model.dart'; + +class GoldRecordItem extends StatelessWidget { + final BillItemModel model; + + const GoldRecordItem(this.model, {super.key}); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 12.sizeBoxH, + Text( + model.tranType ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w400), + ), + 4.sizeBoxH, + Text( + model.desc ?? '', + style: TextStyle( + color: Colors.white.withValues(alpha: .5), + fontSize: 12, + fontWeight: FontWeight.w400), + ), + 4.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + model.createdAt.utcToYMDHMS(), + style: TextStyle( + color: Colors.white.withValues(alpha: .5), + fontSize: 12, + fontWeight: FontWeight.w400), + ), + Text( + '${model.realCount}${model.unit}', + style: const TextStyle( + color: Color(0xffF68804), + fontSize: 14, + fontWeight: FontWeight.w400), + ), + ], + ), + 12.sizeBoxH, + 1.line, + ], + ), + ), + ], + ); + } +} + +//充值记录 +class RechargeRecordItem extends StatelessWidget { + final ListBean model; + + const RechargeRecordItem(this.model, {super.key}); + + @override + Widget build(BuildContext context) { + return Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${model.productName ?? ""}", + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + 2.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () { + ///复制到剪切板 + Clipboard.setData(ClipboardData(text: model.orderId ?? '')); + showToast('复制成功'); + }, + child: Row( + children: [ + Flexible( + child: Text( + '账单编号' + ': ${model.orderId}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: Color(0xE5FFFFFF), + fontWeight: FontWeight.w500), + ), + ), + 2.sizeBoxW, + Image.asset( + 'icon_copy.png'.mineImgPath, + width: 24, + ), + ], + ), + ), + 2.sizeBoxH, + Text( + "状态: ${getStatus(model.status ?? 0)}", + style: TextStyle( + color: Color(0xffF68804), + fontSize: 12, + ), + ), + 2.sizeBoxH, + Text( + model.createdAt?.utcToYMD() ?? "", + style: TextStyle(fontSize: 12, color: Color(0xff525252)), + ), + 12.sizeBoxH, + 0.5.line, + ], + ), + ); + } + + ///支付状态 + String getStatus(int status) { + switch (status) { + case 1: + return "进行中"; + case 2: + return "购买失败"; + case 3: + return "购买成功"; + } + return "未知"; + } + + Color getStatusColor(int status) { + var statusStr = Color(0xffffd382); + switch (status) { + case 2: + statusStr = Color(0xffFF1060); + break; + case 3: + statusStr = Color(0xff28C445); + break; + } + + return statusStr; + } +} + +class InComeRecordItem extends StatelessWidget { + final IncomeModel model; + const InComeRecordItem(this.model, {super.key}); + + @override + Widget build(BuildContext context) { + return Container( + child: Column(children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${model.tranType}", + style: const TextStyle( + color: Color(0xFFEFEFEF), + fontWeight: FontWeight.w500, + fontSize: 14.0), + ), + Container( + margin: EdgeInsets.symmetric(vertical: 4), + child: Text( + model.desc ?? "", + style: TextStyle(fontSize: 12, color: Colors.white60), + ), + ), + Text( + DateTimeUtil.utc2iso(model.createdAt ?? ''), + style: const TextStyle( + color: Color(0xFF525252), + fontWeight: FontWeight.w400, + fontSize: 12.0), + ), + 12.sizeBoxH, + ], + ), + ), + 12.sizeBoxW, + Text( + model.tranTypeInt == 111 + ? "+${model.actualAmount}次" + : "+${model.actualAmount}金币", + style: const TextStyle( + color: const Color(0xFFF68804), + fontWeight: FontWeight.w400, + fontSize: 12.0), + textAlign: TextAlign.left) + ], + ), + 0.5.line, + ])); + } +} + +//提现明细cell +class WithdrawalRecordItem extends StatefulWidget { + final ListBean model; + const WithdrawalRecordItem(this.model, {super.key}); + + @override + State createState() => _WithdrawalRecordItemState(); +} + +class _WithdrawalRecordItemState extends State { + ListBean get model => widget.model; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 12.sizeBoxH, + Text( + '${(model.money ?? 0) ~/ 100}元', + style: TextStyle( + fontSize: 14, + color: AppColors.actionRed, + fontWeight: FontWeight.w500), + ), + 6.sizeBoxH, + Row( + children: [ + Expanded( + child: Text( + '账单编号' + ': ${model.id}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: Color(0xE5FFFFFF), + fontWeight: FontWeight.w500), + ), + ), + InkWell( + enableFeedback: false, + onTap: () { + ///复制到剪切板 + Clipboard.setData(ClipboardData(text: model.id ?? '')); + showToast('复制成功'); + }, + child: Image.asset( + 'mine_copy.png'.mineImgPath, + width: 24, + ), + ) + ], + ), + 8.sizeBoxH, + Text( + '${getPayType(model.payType ?? "")}${getStatus(model.status ?? 0)}', + style: TextStyle(fontSize: 12, color: getStatusColor()), + ), + 6.sizeBoxH, + Row( + children: [ + Text( + DateTimeUtil.utc2iso(model.createdAt ?? ''), + style: TextStyle(fontSize: 12, color: Color(0x8CFFFFFF)), + ), + Spacer(), + if (model.status != 5 && model.status != 1) ...[ + InkWell( + enableFeedback: false, + onTap: () => setState(() { + model.showReason = !model.showReason; + }), + child: Text( + '查看原因', + style: TextStyle(fontSize: 12, color: AppColors.actionRed), + ), + ) + ], + ], + ), + if (model.showReason) ...[ + 12.sizeBoxH, + Text( + '${model.statusDesc}', + style: TextStyle(fontSize: 12, color: Colors.white), + ), + ], + 12.sizeBoxH, + 0.5.line, + ], + ), + ); + } + + String getPayType(String payType) { + if (payType.endsWith("alipay")) { + return "支付宝"; + } else if (payType.endsWith("usdt")) { + return "USDT"; + } else { + return "银行卡"; + } + } + + ///支付状态 + String getStatus(int status) { + switch (status) { + case 1: + return '提现审核中'; + case 2: + return '审核通过,转账中'; + case 3: + return '提现已拒绝'; + case 4: + return '未知错误'; + case 5: + return '提现成功'; + case 6: + return '提现失败'; + } + return ''; + } + + ///支付状态 + Color getStatusColor() { + if (model.status == 5) { + return Color(0xff0360FC); + } else { + return Color(0x8CFFFFFF); + } + } +} diff --git a/lib/hj_page/mine/make_money/withdraw_details_model.dart b/lib/hj_page/mine/make_money/withdraw_details_model.dart new file mode 100644 index 0000000..5bc268e --- /dev/null +++ b/lib/hj_page/mine/make_money/withdraw_details_model.dart @@ -0,0 +1,369 @@ +/// hasNext : true + +class WithdrawDetailsModel { + bool? hasNext; + List? list; + List? result; + int? total; + + static WithdrawDetailsModel? fromJson(Map map) { + WithdrawDetailsModel withdrawDetailsModel = WithdrawDetailsModel(); + withdrawDetailsModel.hasNext = map['hasNext']; + withdrawDetailsModel.list = []..addAll((map['list'] as List? ?? []).map((o) => ListBean.fromMap(o))); + withdrawDetailsModel.result = []..addAll((map['result'] as List? ?? []).map((o) => ResultBean.fromMap(o))); + withdrawDetailsModel.total = map['total']; + return withdrawDetailsModel; + } + + Map toJson() => { + "hasNext": hasNext, + "list": list, + "result": result, + "total": total, + }; +} + +class ResultBean { + int? uid; + int? amount; + int? money; + int? payMoney; + int? withdrawType; + int? status; + String? id; + String? name; + String? oid; + String? payType; + String? actName; + String? act; + String? userIp; + String? deviceType; + String? devID; + String? statusDesc; + String? checkedAt; + String? progressAt; + String? failureAt; + String? successAt; + String? updatedAt; + String? createdAt; + String? receivedAt; + + static ResultBean fromMap(Map map) { + ResultBean info = ResultBean(); + info.id = map['id']; + info.uid = map['uid']; + info.name = map['name']; + info.amount = map['amount']; + info.oid = map['oid']; + info.money = map['money']; + info.payMoney = map['payMoney']; + info.payType = map['payType']; + info.withdrawType = map['withdrawType']; + info.actName = map['actName']; + info.act = map['act']; + info.userIp = map['userIp']; + info.deviceType = map['deviceType']; + info.devID = map['devID']; + info.status = map['status']; + info.statusDesc = map['statusDesc']; + info.checkedAt = map['checkedAt']; + info.progressAt = map['progressAt']; + info.failureAt = map['failureAt']; + info.successAt = map['successAt']; + info.updatedAt = map['updatedAt']; + info.createdAt = map['createdAt']; + info.receivedAt = map['receivedAt']; + return info; + } + + Map toJson() => { + "id": id, + "uid": uid, + "name": name, + "amount": amount, + "oid": oid, + "money": money, + "payMoney": payMoney, + "payType": payType, + "withdrawType": withdrawType, + "actName": actName, + "act": act, + "userIp": userIp, + "deviceType": deviceType, + "devID": devID, + "status": status, + "statusDesc": statusDesc, + "checkedAt": checkedAt, + "progressAt": progressAt, + "failureAt": failureAt, + "successAt": successAt, + "updatedAt": updatedAt, + "createdAt": createdAt, + "receivedAt": receivedAt, + }; +} + +class ListBean { + int? money; + int? payMoney; + int? uid; + int? amount; + int? withdrawType; + int? status; + String? id; + String? orderId; + String? name; + String? oid; + String? payType; + String? actName; + String? act; + String? userIp; + String? deviceType; + String? devID; + String? statusDesc; + String? checkedAt; + String? progressAt; + String? failureAt; + String? successAt; + String? updatedAt; + String? createdAt; + String? desc; + String? receivedAt; + String? productName; + double? actualAmount; + bool showReason = false; //本地添加字段,是否展示原因 + + static ListBean fromMap(Map map) { + ListBean info = ListBean(); + info.id = map['id']; + info.orderId = map['orderId']; + info.uid = map['uid']; + info.name = map['name']; + info.amount = map['amount']; + info.oid = map['oid']; + info.money = map['money']; + info.payMoney = map['payMoney']; + info.payType = map['payType']; + info.withdrawType = map['withdrawType']; + info.actName = map['actName']; + info.act = map['act']; + info.userIp = map['userIp']; + info.deviceType = map['deviceType']; + info.devID = map['devID']; + info.status = map['status']; + info.statusDesc = map['statusDesc']; + info.checkedAt = map['checkedAt']; + info.progressAt = map['progressAt']; + info.failureAt = map['failureAt']; + info.successAt = map['successAt']; + info.updatedAt = map['updatedAt']; + info.createdAt = map['createdAt']; + info.receivedAt = map['receivedAt']; + info.desc = map['desc']; + info.actualAmount = map['actualAmount']?.toDouble() ?? .0; + info.productName = map['productName']; + return info; + } + + Map toJson() => { + "id": id, + "uid": uid, + "name": name, + "amount": amount, + "oid": oid, + "money": money, + "payMoney": payMoney, + "payType": payType, + "withdrawType": withdrawType, + "actName": actName, + "act": act, + "userIp": userIp, + "deviceType": deviceType, + "devID": devID, + "status": status, + "statusDesc": statusDesc, + "checkedAt": checkedAt, + "progressAt": progressAt, + "failureAt": failureAt, + "successAt": successAt, + "updatedAt": updatedAt, + "createdAt": createdAt, + "receivedAt": receivedAt, + "desc": desc, + "actualAmount": actualAmount, + }; +} + +class IncomeModel { + String? id; + int? uid; + String? purchaseOrder; + String? productID; + num? amount; + num? integral; + String? realIntegral; + num? actualIntegral; + num? actualAmount; + num? tax; + num? taxAmount; + String? channelType; + String? tranType; + num? tranTypeInt; + num? performance; + num? rechargeId; + RechargeUser? rechargeUser; + String? desc; + String? createdAt; + String? sysType; + num? agentLevel; + num? vipLevel; + String? realAmount; + String? money; + String? wlRealAmount; + num? fruitCoin; + num? downloadCount; + num? fruitCoinBalance; + num? aiMateBalance; + String? districtCode; + String? promSeqe; + bool? isDirect; + String? discBindAt; + + IncomeModel( + {this.id, + this.uid, + this.purchaseOrder, + this.productID, + this.amount, + this.integral, + this.realIntegral, + this.actualIntegral, + this.actualAmount, + this.tax, + this.taxAmount, + this.channelType, + this.tranType, + this.tranTypeInt, + this.performance, + this.rechargeId, + this.rechargeUser, + this.desc, + this.createdAt, + this.sysType, + this.agentLevel, + this.vipLevel, + this.realAmount, + this.money, + this.wlRealAmount, + this.fruitCoin, + this.downloadCount, + this.fruitCoinBalance, + this.aiMateBalance, + this.districtCode, + this.promSeqe, + this.isDirect, + this.discBindAt}); + + handelMoney() { + double price = (actualAmount ?? 0) / 10; + return price.toStringAsFixed(2); + } + + IncomeModel.fromJson(Map json) { + id = json['id']; + uid = json['uid']; + purchaseOrder = json['purchaseOrder']; + productID = json['productID']; + amount = json['amount']; + integral = json['integral']; + realIntegral = json['realIntegral']; + actualIntegral = json['actualIntegral']; + actualAmount = json['actualAmount']; + tax = json['tax']; + taxAmount = json['taxAmount']; + channelType = json['channelType']; + tranType = json['tranType']; + tranTypeInt = json['tranTypeInt']; + performance = json['performance']; + rechargeId = json['rechargeId']; + rechargeUser = json['rechargeUser'] != null ? new RechargeUser.fromJson(json['rechargeUser']) : null; + desc = json['desc']; + createdAt = json['createdAt']; + sysType = json['sysType']; + agentLevel = json['agentLevel']; + vipLevel = json['vipLevel']; + realAmount = json['realAmount']; + money = json['money']; + wlRealAmount = json['wlRealAmount']; + fruitCoin = json['fruitCoin']; + downloadCount = json['downloadCount']; + fruitCoinBalance = json['fruitCoinBalance']; + aiMateBalance = json['aiMateBalance']; + districtCode = json['districtCode']; + promSeqe = json['promSeqe']; + isDirect = json['isDirect']; + discBindAt = json['DiscBindAt']; + } + + Map toJson() { + final Map data = new Map(); + data['id'] = this.id; + data['uid'] = this.uid; + data['purchaseOrder'] = this.purchaseOrder; + data['productID'] = this.productID; + data['amount'] = this.amount; + data['integral'] = this.integral; + data['realIntegral'] = this.realIntegral; + data['actualIntegral'] = this.actualIntegral; + data['actualAmount'] = this.actualAmount; + data['tax'] = this.tax; + data['taxAmount'] = this.taxAmount; + data['channelType'] = this.channelType; + data['tranType'] = this.tranType; + data['tranTypeInt'] = this.tranTypeInt; + data['performance'] = this.performance; + data['rechargeId'] = this.rechargeId; + if (this.rechargeUser != null) { + data['rechargeUser'] = this.rechargeUser!.toJson(); + } + data['desc'] = this.desc; + data['createdAt'] = this.createdAt; + data['sysType'] = this.sysType; + data['agentLevel'] = this.agentLevel; + data['vipLevel'] = this.vipLevel; + data['realAmount'] = this.realAmount; + data['money'] = this.money; + data['wlRealAmount'] = this.wlRealAmount; + data['fruitCoin'] = this.fruitCoin; + data['downloadCount'] = this.downloadCount; + data['fruitCoinBalance'] = this.fruitCoinBalance; + data['aiMateBalance'] = this.aiMateBalance; + data['districtCode'] = this.districtCode; + data['promSeqe'] = this.promSeqe; + data['isDirect'] = this.isDirect; + data['DiscBindAt'] = this.discBindAt; + return data; + } +} + +class RechargeUser { + int? uid; + String? name; + String? portrait; + + RechargeUser({this.uid, this.name, this.portrait}); + + RechargeUser.fromJson(Map json) { + uid = json['uid']; + name = json['name']; + portrait = json['portrait']; + } + + Map toJson() { + final Map data = new Map(); + data['uid'] = this.uid; + data['name'] = this.name; + data['portrait'] = this.portrait; + return data; + } +} diff --git a/lib/hj_page/mine/make_money/withdrawal_logic.dart b/lib/hj_page/mine/make_money/withdrawal_logic.dart new file mode 100644 index 0000000..72bdadd --- /dev/null +++ b/lib/hj_page/mine/make_money/withdrawal_logic.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/user/wallet_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_helper.dart'; + +import '../../../alert/mine/vip_level_dialog.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import '../../../track_event_manager/device_service.dart'; +import '../mine_profit/bank_card_home_page.dart'; +import '../mine_profit/model/alipay_bank_list_model.dart'; +import 'in_come_entity.dart'; + +class WithdrawalLogic extends GetxController with GetTickerProviderStateMixin { + WithdrawalLogic get to => Get.find(); + + TextEditingController? moneyController; + TextEditingController? accountController; + FocusNode focusNode = FocusNode(); + bool isShowLoading = false; + + WalletModel? userIncomeModel; + WithdrawConfig? configData; + TextEditingController? nameController; + bool isLoading = true; + + int withdrawType = 1; // 0支付宝 1银行卡 + Channel? selectChannel; + + num handlingFee = 0; //手续费 + num actualAmount = 0; //实际到账金额 + AccountInfoModel? bankModel; + int get minBankCardMoney { + return (configData?.getBankCardChannel?.minMoney ?? 0) ~/ 100; + } + + int get minUsdtMoney { + return (configData?.getUsdtChannel?.minMoney ?? 0) ~/ 100; + } + + @override + void onInit() { + super.onInit(); + moneyController = TextEditingController(); + nameController = TextEditingController(); + accountController = TextEditingController(); + } + + @override + void onReady() { + super.onReady(); + loadData(showLoading: true); + globalStore.refreshWallet(); + } + + void changeWithdrawType(String payType) { + // withdrawType = value; + selectChannel = configData?.channels + ?.firstWhere((element) => element.payType == payType); + withdrawType = configData?.channels + ?.indexWhere((e) => e.payType == selectChannel?.payType) ?? + 0; + update(); + } + + ///计算提现手续费、实际到账金额 + void calcWithdrawAmount(String withdrawAmount) { + if (withdrawAmount.isEmpty) { + handlingFee = 0; + actualAmount = 0; + } else { + num withdrawAmoutNum = num.parse(withdrawAmount); + int coinTax = configData?.coinTax ?? 0; + if (coinTax == 0) { + handlingFee = 0; + actualAmount = withdrawAmoutNum; + } else { + double value = coinTax / 100.0; + final res = (withdrawAmoutNum * double.parse(value.toStringAsFixed(2))) + .toStringAsFixed(2); + handlingFee = double.parse(res).floor(); + actualAmount = withdrawAmoutNum - handlingFee; + } + } + update(['money']); + } + + @override + void dispose() { + super.dispose(); + nameController?.dispose(); + moneyController?.dispose(); + accountController?.dispose(); + focusNode.dispose(); + } + + Future loadData({ + bool isRefresh = true, + bool showLoading = false, + }) async { + //获取支付宝或者银行卡的费率 + _withdrawConfigReq(); + } + + ///提现配置请求 + void _withdrawConfigReq() async { + final configData = await MineService.withdrawConfig(); + this.configData = configData; + isLoading = false; + // 优先银行卡,其次 usdt + selectChannel = configData?.channels + ?.firstWhereOrNull((element) => element.payType == 'bankcard') ?? + configData?.channels + ?.firstWhereOrNull((element) => element.payType == 'usdt'); + if (selectChannel == null) { + showToast('暂无可用提现通道'); + this.configData = null; + } else { + // 必须同步 withdrawType,页面按 channels[withdrawType] 取值,否则会索引错位甚至越界 + withdrawType = configData?.channels + ?.indexWhere((e) => e.payType == selectChannel?.payType) ?? + 0; + } + update(); + } + + ///提交提现 + void submitWithdraw() async { + try { + if (!globalStore.isRechargeVIP) { + showVipLevelDialog("您还不是VIP,无法使用提现功能"); + return; + } + + if ("bankcard" == selectChannel?.payType) { + //检验银行卡信息 + _commonWithdrawReq(true); + } else if ("alipay" == selectChannel?.payType || + "usdt" == selectChannel?.payType) { + _commonWithdrawReq(false); + } + } catch (e) { + showToast("提现错误:$e"); + } + } + + ///公用提现方法 + void _commonWithdrawReq(bool isbankType) async { + Channel channel = configData!.channels![withdrawType]; + String money = moneyController?.text.trim() ?? ""; + String accountDesc = accountController?.text.trim() ?? ""; + if (isbankType) { + accountDesc = bankModel?.act ?? ""; + } + if (money.isEmpty) { + showToast("提现金额不能为空"); + return; + } + if (isbankType && bankModel == null) { + showToast("请选择提现银行卡号"); + return; + } + if (!isbankType && accountDesc.isEmpty) { + showToast("请输入钱包地址"); + return; + } + + double incomeMoneyYuan = (userIncomeModel?.balance ?? 0) / 10; + int withdrawMoneyYuan = int.parse(money); + if (withdrawMoneyYuan > incomeMoneyYuan) { + showToast("提现金额不能大于余额"); + return; + } + + int minMoneyFen = configData?.channels![withdrawType].minMoney ?? 0; + double minMoneyYuan = minMoneyFen / 100; + if (withdrawMoneyYuan < minMoneyYuan) { + showToast("单笔提现金额不小于$minMoneyYuan元"); + return; + } + int maxMoneyFen = configData?.channels![withdrawType].maxMoney ?? 0; + double maxMoneyYuan = maxMoneyFen / 100; + if (withdrawMoneyYuan > maxMoneyYuan) { + showToast("单笔提现金额不大于$maxMoneyYuan元"); + return; + } + + LoadingHelper.showLoading(); + + String deviceId = DeviceInfoService.deviceId; + String payType = channel.payType ?? ""; + //payType 提现方式,alipay,bankcard,usdt + //money 提现金额 + //name 用户名 + //withdrawType 提现类型,0,代理提现; 1,金币提现 + //actName 交易账户持有人 + //act 交易账户 + //devID 设备id + //productType 产品类型 0站群 1棋牌 + var result = await MineService.withdraw( + payType, + accountDesc, + withdrawMoneyYuan * 100, + globalStore.meInfo?.name ?? "", + bankModel?.actName, + deviceId, + bankModel?.bankCode, + 1, + 0, + ); + LoadingHelper.dismissLoading(); + if (result == true) { + globalStore.refreshWallet(refresh: true); + showToast("提现提交成功~"); + + clearInputData(); + } else { + showToast("提现失败~"); + } + } + + void gotoBankList() async { + var ret = await Get.to( + BankCardHomePage(selectModel: bankModel), + preventDuplicates: false, + ); + if (ret is AccountInfoModel) { + bankModel = ret; + update(); + } + } + + clearInputData() { + nameController?.clear(); + accountController?.clear(); + moneyController?.clear(); + } +} diff --git a/lib/hj_page/mine/make_money/withdrawal_page.dart b/lib/hj_page/mine/make_money/withdrawal_page.dart new file mode 100644 index 0000000..e8fbf41 --- /dev/null +++ b/lib/hj_page/mine/make_money/withdrawal_page.dart @@ -0,0 +1,516 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/mine/widgets/gradient_text.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../assets_tool/app_colors.dart'; +import '../../../hj_utils/widget_util.dart'; +import '../../../routers/jump_router.dart'; +import 'in_come_entity.dart'; +import 'mine_withdrawal_record_page.dart'; +import 'withdrawal_logic.dart'; + +//我要提现页面 +class WithdrawalPage extends StatelessWidget { + const WithdrawalPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: WithdrawalLogic(), + builder: (logic) { + return Scaffold( + appBar: AppBar( + title: Text( + '我要提现', + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + actions: [ + GestureDetector( + child: Text( + '明细', + style: TextStyle(color: Color(0x73FFFFFF), fontSize: 12), + ), + onTap: () => Get.to(RecordsPage(RecordType.withdraw)), + ), + 16.w.sizeBoxW, + ], + ), + body: () { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.configData == null) return CErrorWidget(); + const measureStyle = TextStyle(fontSize: 14); + var textPainter = TextPainter( + text: TextSpan( + text: '', + style: measureStyle, + ), + textDirection: TextDirection.ltr, + textWidthBasis: TextWidthBasis.longestLine, + )..layout(); + + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + child: Container( + margin: EdgeInsets.only(left: 16.w, right: 16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: + EdgeInsets.only(top: 12.w, bottom: 18.w), + padding: EdgeInsets.symmetric(vertical: 12.w), + decoration: BoxDecoration( + color: Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Row( + children: [ + Container( + height: 22, + width: 8, + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(11), + bottomRight: Radius.circular(11), + ), + color: AppColors.actionRed, + ), + ), + 12.sizeBoxW, + Text( + "余额(元)", + style: const TextStyle( + color: AppColors.actionRed, + fontWeight: FontWeight.w400, + fontSize: 14.0, + ), + ), + ], + ), + 12.sizeBoxH, + Padding( + padding: EdgeInsets.only(left: 20), + child: Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + Image.asset( + 'coin_icon.webp'.mineImgPath, + width: 30), + 4.sizeBoxW, + Consumer( + builder: (_, store, __) { + logic.userIncomeModel = + store.wallet; + return GradientText( + ((store.wallet?.balance ?? 0) * + 10 / + 100) + .toStringAsFixed(2), + gradient: LinearGradient( + colors: [ + Color(0xffFFE8BE), + Color(0xffE6B764) + ], + tileMode: TileMode.mirror, + ), + style: TextStyle( + fontSize: 31.5, + fontWeight: FontWeight + .w500), //目前fontSize设置为31.5 设置为32,显示不完全,存在系统bug + ); + }), + ], + ), + ), + ], + )), + Row( + children: [ + Text( + '提现币类:', + style: textStyle( + 14, Color(0xE5FFFFFF), FontWeight.w500), + ), + 15.sizeBoxW, + Text( + '人民币', + style: textStyle( + 14, Color(0x8CFFFFFF), FontWeight.w400), + ), + ], + ), + 16.sizeBoxH, + Row( + children: [ + Text( + "提现金额:", + style: textStyle( + 14, Color(0xE5FFFFFF), FontWeight.w500), + ), + 10.sizeBoxW, + Expanded( + child: Container( + alignment: Alignment.centerLeft, + height: 40, + padding: + EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: + Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(6), + ), + child: TextField( + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp("[0-9]")), + LengthLimitingTextInputFormatter(9), + ], + controller: logic.moneyController, + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w400), + cursorColor: + Colors.white.withValues(alpha: 0.7), + decoration: InputDecoration( + border: InputBorder.none, + contentPadding: + EdgeInsets.only(bottom: 8), + hintStyle: TextStyle( + color: Color(0xFF525252), + fontSize: 12, + fontWeight: FontWeight.w400), + hintText: + '单笔提现金额范围 ${_getPayMoneyRange(logic)}元', //"您目前 + ), + focusNode: logic.focusNode, + onChanged: (value) => + logic.calcWithdrawAmount(value)), + ), + ) + ], + ), + 16.sizeBoxH, + Row( + children: [ + Text( + "提现方式:", + style: textStyle( + 14, Color(0xE5FFFFFF), FontWeight.w500), + ), + 16.sizeBoxW, + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + height: 30, + child: Row( + children: logic.configData?.realChannel + .asMap() + .map((index, e) => MapEntry( + index, + _buildPayTypeUI( + e, logic, index))) + .values + .toList() ?? + [], + ), + ), + ), + ), + ], + ), + 16.sizeBoxH, + Row( + children: [ + if (_isUsdt(logic)) ...[ + Text( + 'USDT地址:', + style: textStyle( + 14, Color(0xE5FFFFFF), FontWeight.w500), + ), + 10.sizeBoxW, + Expanded( + child: Container( + decoration: BoxDecoration( + color: + Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(6), + ), + child: TextField( + textAlignVertical: + TextAlignVertical.center, + controller: logic.accountController, + maxLines: 1, + decoration: InputDecoration( + border: InputBorder.none, + hintText: _getHintText(logic), + hintStyle: TextStyle( + color: Color(0xff999999), + fontSize: 14, + fontWeight: FontWeight.w400), + isCollapsed: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: + (32 - textPainter.height) / 2, + ), + ), + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + ) + ] else ...[ + Text( + '银行卡号:', + style: textStyle( + 14, Color(0xE5FFFFFF), FontWeight.w500), + ), + 10.sizeBoxW, + Expanded( + child: InkWell( + enableFeedback: false, + onTap: () => logic.gotoBankList(), + child: Container( + alignment: Alignment.centerLeft, + height: 37, + padding: EdgeInsets.only(left: 10), + decoration: BoxDecoration( + color: + Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Expanded( + child: Text( + logic.bankModel == null + ? "请选择提现银行账号" + : "${logic.bankModel?.getBankName()}(${logic.bankModel?.act?.substring((logic.bankModel?.act?.length ?? 4) - 4)}) ${logic.bankModel?.actName}", + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + color: logic.bankModel == null + ? Color(0xff525252) + : Colors.white, + ), + ), + ), + 6.sizeBoxW, + Icon(Icons.keyboard_arrow_right, + color: Colors.white, size: 16), + 10.sizeBoxW, + ], + ), + ), + )), + ] + ], + ), + 16.sizeBoxH, + GetBuilder( + init: logic, + id: 'money', + builder: (controller) => EasyRichText( + '手续费率: ${logic.configData?.coinTax ?? 0}% 实际到账金额: ${logic.actualAmount}', + defaultStyle: textStyle( + 14, Color(0xE5FFFFFF), FontWeight.w500), + patternList: [ + EasyRichTextPattern( + targetString: + '${logic.configData?.coinTax ?? 0}%', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0x8CFFFFFF), + ), + ), + EasyRichTextPattern( + targetString: + '实际到账金额: ${logic.handlingFee}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0x8CFFFFFF), + ), + ), + EasyRichTextPattern( + targetString: '${logic.actualAmount}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0x8CFFFFFF), + ), + ), + ], + ), + ), + 18.sizeBoxH, + Text( + "提现规则:", + style: const TextStyle( + color: Color(0xE5FFFFFF), + fontWeight: FontWeight.w500, + fontSize: 16.0), + ), + 12.sizeBoxH, + Text( + "1、每次提现金额最低${(logic.configData?.channels == null ? 0 : logic.selectChannel?.minMoney ?? 0) ~/ 100}元起," + "单笔提现最大${(logic.configData?.channels == null ? 0 : logic.selectChannel?.maxMoney ?? 0) ~/ 100}元,且为整数。\n" + "2、每次提现收取${logic.configData?.coinTax}%手续费。\n" + "3、仅支持银行卡提现,收款账户卡号与姓名一致,到账时间未72小时内。\n" + "4、申请提现后请随时关注收款账户进款通知,长时间未到账,请及时联系客服。\n", + style: const TextStyle( + color: Color(0x8CFFFFFF), + fontWeight: FontWeight.w400, + fontSize: 12.0, + height: 2), + ), + 30.sizeBoxH, + ], + ), + ), + ), + ), + Column( + children: [ + GestureDetector( + onTap: () => logic.submitWithdraw(), + child: Container( + height: 47, + alignment: Alignment.center, + margin: EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + '立即提现', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + )), + ), + 10.sizeBoxH, + EasyRichText( + '提现中如有问题,请联系 在线客服', + defaultStyle: + textStyle(12, Colors.white, FontWeight.w400), + patternList: [ + EasyRichTextPattern( + targetString: '在线客服', + style: TextStyle(color: Color(0xffFFD460)), + recognizer: TapGestureRecognizer() + ..onTap = pushToCustomService, + ), + ], + ), + 34.sizeBoxH, + ], + ) + ], + ); + }()); + }, + ); + } + + ///支付类型 + Widget _buildPayTypeUI(Channel channel, WithdrawalLogic logic, int index) { + bool isSelected = logic.selectChannel?.payType == channel.payType; + return InkWell( + enableFeedback: false, + onTap: () => logic.changeWithdrawType(channel.payType ?? ''), + child: Row( + children: [ + Image.asset( + channel.payType == 'usdt' + ? 'ic_usdt.png'.mineImgPath + : 'ic_union.png'.mineImgPath, + width: 30), + 4.sizeBoxW, + Text(_getPayTypeName(channel.payType ?? ""), + style: TextStyle(color: Colors.white, fontSize: 14)), + 10.sizeBoxW, + Image.asset( + isSelected + ? 'radio_sel.png'.commonImgPath + : 'mine_withdraw_nor.png'.mineImgPath, + width: 24, + ), + 24.sizeBoxW, + ], + ), + ); + } + + ///获取支付方式名称 + String _getPayTypeName(String payType) { + if ("alipay" == payType) { + return "支付宝"; + } else if ("bankcard" == payType) { + return "银行卡"; + } else if ("usdt" == payType) { + return "USDT"; + } + return "银行卡"; + } + + ///获取提现金额范围 + String _getPayMoneyRange(WithdrawalLogic logic) { + final channel = _currentChannel(logic); + if (channel == null) return "0"; + return "${(channel.minMoney ?? 0) / 100}-${(channel.maxMoney ?? 0) / 100}"; + } + + String _getHintText(WithdrawalLogic logic) { + final channel = _currentChannel(logic); + switch (channel?.payType ?? "") { + case 'alipay': + return '请输入支付宝账号'; + case 'bankcard': + return '请输入银行卡号'; + case 'usdt': + return '请输入USDT地址'; + default: + return ""; + } + } + + bool _isUsdt(WithdrawalLogic logic) => + (_currentChannel(logic)?.payType ?? "") == 'usdt'; + + // 按 withdrawType 取当前通道,带越界保护(配置异常时不崩溃) + Channel? _currentChannel(WithdrawalLogic logic) { + final channels = logic.configData?.channels ?? []; + final index = logic.withdrawType; + if (index < 0 || index >= channels.length) return null; + return channels[index]; + } +} diff --git a/lib/hj_page/mine/message/message_center_logic.dart b/lib/hj_page/mine/message/message_center_logic.dart new file mode 100644 index 0000000..96a98d2 --- /dev/null +++ b/lib/hj_page/mine/message/message_center_logic.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../hj_utils/api_service/mine_service.dart'; + +class MessageCenterLogic extends GetxController with GetTickerProviderStateMixin { + MessageCenterLogic get to => Get.find(); + + late TabController tabController = TabController(length: titleArr.length, vsync: this); + + List titleArr = ['评论', '点赞']; + + void getUnReadNum() async { + await MineService.getUnreadNum(); + update(); + } +} diff --git a/lib/hj_page/mine/message/message_center_page.dart b/lib/hj_page/mine/message/message_center_page.dart new file mode 100644 index 0000000..64fcdb1 --- /dev/null +++ b/lib/hj_page/mine/message/message_center_page.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import 'message_center_logic.dart'; +import 'message_center_sub_page.dart'; + +class MessageCenterPage extends StatelessWidget { + const MessageCenterPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + '消息中心', + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 18, + fontWeight: FontWeight.w600, + ), + )), + body: GetBuilder( + init: MessageCenterLogic(), + builder: (logic) { + return Column( + children: [ + TabBar( + isScrollable: true, + labelStyle: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: .9), + fontWeight: FontWeight.w500, + ), + unselectedLabelStyle: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: .45), + ), + indicator: BoxDecoration(), + tabAlignment: TabAlignment.start, + controller: logic.tabController, + labelPadding: EdgeInsets.symmetric(horizontal: 0), + tabs: logic.titleArr + .map((it) => Container( + padding: EdgeInsets.symmetric(horizontal: 12), + margin: EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: it == logic.titleArr.first + ? Colors.transparent + : Color(0x1AFFFFFF), + width: 1)), + ), + child: Text(it), + )) + .toList(), + ), + Expanded( + child: TabBarView( + controller: logic.tabController, + children: logic.titleArr + .asMap() + .map((key, value) => MapEntry( + key, + MessageCenterSubPage(MessageType.values[key]) + .keepAlive)) + .values + .toList()), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/hj_page/mine/message/message_center_sub_controller.dart b/lib/hj_page/mine/message/message_center_sub_controller.dart new file mode 100644 index 0000000..a8c5481 --- /dev/null +++ b/lib/hj_page/mine/message/message_center_sub_controller.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import 'message_center_sub_page.dart'; +import 'widget/message_items.dart'; + +abstract class MessageCenterSubLogic extends GetxController { + final dataSource = []; + int page = 1; + bool isLoading = true; + + RefreshController? refreshController; + + @override + void onReady() { + super.onReady(); + fetchPageData(); + } + + @mustCallSuper + fetchPageData({bool isRefresh = true, bool showLoading = false}) { + if (isRefresh) { + page = 1; + } + if (showLoading) { + isLoading = true; + update(); + } + } + + Widget instanceChildItem(int index); +} + +//评论 +class MessageCommentLogic extends MessageCenterSubLogic { + @override + Widget instanceChildItem(int index) { + return MessageCommentItem(dataSource[index]); + } + + @override + fetchPageData({bool isRefresh = true, bool showLoading = false}) async { + super.fetchPageData(isRefresh: isRefresh, showLoading: showLoading); + final res = await MineService.getDynamicList(pageNumber: page, msgType: 2); + isLoading = false; + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + res?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(res?.list ?? []); + page += 1; + update(); + } +} + +//点赞 +class MessageThumbUpLogic extends MessageCenterSubLogic { + @override + fetchPageData({bool isRefresh = true, bool showLoading = false}) async { + super.fetchPageData(isRefresh: isRefresh, showLoading: showLoading); + final res = await MineService.getDynamicList(pageNumber: page, msgType: 1); + isLoading = false; + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + res?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(res?.list ?? []); + page += 1; + update(); + } + + @override + Widget instanceChildItem(int index) { + return MessageThumbItem(dataSource[index]); + } +} + +MessageCenterSubLogic instanceController(MessageType type) { + switch (type) { + case MessageType.comment: + return MessageCommentLogic(); + case MessageType.thumb: + return MessageThumbUpLogic(); + default: + throw '未知的类型${type}'; + } +} diff --git a/lib/hj_page/mine/message/message_center_sub_page.dart b/lib/hj_page/mine/message/message_center_sub_page.dart new file mode 100644 index 0000000..dee8d99 --- /dev/null +++ b/lib/hj_page/mine/message/message_center_sub_page.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../../../main.dart'; +import 'message_center_sub_controller.dart'; + +enum MessageType { + comment(1), //评论 + thumb(2); //点赞 + + final int type; + const MessageType(this.type); +} + +class MessageCenterSubPage extends StatefulWidget { + final MessageType type; + const MessageCenterSubPage(this.type, {super.key}); + + @override + State createState() => _MessageCenterSubPageState(); +} + +class _MessageCenterSubPageState extends State + with RouteAware { + late MessageCenterSubLogic logic; + + @override + void initState() { + super.initState(); + logic = instanceController(widget.type); + } + + void didChangeDependencies() { + super.didChangeDependencies(); + routeObserver.subscribe(this, ModalRoute.of(context)!); + } + + @override + void didPopNext() { + super.didPopNext(); + } + + @override + dispose() { + super.dispose(); + routeObserver.unsubscribe(this); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: logic, + global: false, + builder: (controller) => pullYsRefresh( + onLoading: (_) => controller.fetchPageData(isRefresh: false), + onRefresh: (_) => controller.fetchPageData(), + onInit: (_) => controller.refreshController = _, + child: () { + if (controller.isLoading) return LoadingCenterWidget(); + if (controller.dataSource.isEmpty) return CErrorWidget(); + return ListView.builder( + padding: EdgeInsets.only(left: 16, right: 16, top: 12), + itemCount: controller.dataSource.length, + itemBuilder: (BuildContext context, int index) { + return controller.instanceChildItem(index); + }, + ); + }()), + ); + } +} diff --git a/lib/hj_page/mine/message/un_read_msg_num_model.dart b/lib/hj_page/mine/message/un_read_msg_num_model.dart new file mode 100644 index 0000000..d98efbb --- /dev/null +++ b/lib/hj_page/mine/message/un_read_msg_num_model.dart @@ -0,0 +1,35 @@ +class UnReadMsgNumModel { + UnReadMsgNumModel({ + this.dtCount, + this.incomeCount, + this.msgCount, + }); + + UnReadMsgNumModel.fromJson(dynamic json) { + dtCount = json['dtCount']; + incomeCount = json['incomeCount']; + msgCount = json['msgCount']; + } + num? dtCount; + num? incomeCount; + num? msgCount; + UnReadMsgNumModel copyWith({ + num? dtCount, + num? incomeCount, + num? msgCount, + }) => + UnReadMsgNumModel( + dtCount: dtCount ?? this.dtCount, + incomeCount: incomeCount ?? this.incomeCount, + msgCount: msgCount ?? this.msgCount, + ); + Map toJson() { + final map = {}; + map['dtCount'] = dtCount; + map['incomeCount'] = incomeCount; + map['msgCount'] = msgCount; + return map; + } + + num get allNum => (dtCount ?? 0) + (incomeCount ?? 0) + (msgCount ?? 0); +} diff --git a/lib/hj_page/mine/message/widget/message_items.dart b/lib/hj_page/mine/message/widget/message_items.dart new file mode 100644 index 0000000..5247fbd --- /dev/null +++ b/lib/hj_page/mine/message/widget/message_items.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/message/message_dynamic_list.dart'; +import 'package:hgdj/hj_page/user_center_page/user_center_page.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../community/post_detail_page/post_detail_page.dart'; + +/// 点赞 +class MessageThumbItem extends StatelessWidget { + final MessageDynamicList model; + const MessageThumbItem(this.model, {super.key}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Get.to(() => PostDetailPage( + argument: PostDetailPageArgument(id: model.objId, models: []))), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), + margin: EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + "#$lTypeText", + maxLines: 1, + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox(width: 12), + Text( + model.createdAt.utcToYMD(), + style: TextStyle( + color: Color(0xE5FFFFFF), + fontSize: 14, + ), + ), + ], + ), + 7.sizeBoxH, + Text( + '${model.objName}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Color(0x8CFFFFFF), + fontSize: 14, + ), + ), + 6.sizeBoxH, + Row( + children: [ + Text( + "${model.likeCount}人为你点赞", + style: TextStyle( + color: Color(0x59FFFFFF), + fontSize: 14, + ), + ), + ], + ), + ], + ), + ), + ); + } + + String get lTypeText { + // video:对帖子点赞 comment:对评论点赞 + switch (model.msgType) { + case 'like_msg': + return '帖子点赞'; + case 'like_comment_msg': + return '评论点赞'; + default: + return ''; + } + } +} + +class MessageCommentItem extends StatelessWidget { + final MessageDynamicList model; + + const MessageCommentItem(this.model, {super.key}); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: () => Get.to(() => PostDetailPage( + argument: PostDetailPageArgument(id: model.objId, models: []))), + child: Container( + padding: EdgeInsets.symmetric(vertical: 18, horizontal: 12), + margin: EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + enableFeedback: false, + onTap: () => + Get.to(() => UserCenterPage(uid: model.sendUid ?? 0)), + child: NetworkImageLoader( + imageUrl: model.sendAvatar ?? '', + width: 40, + height: 40, + borderRadius: 20, + ), + ), + 12.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 40, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + model.sendName ?? '', + style: TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + Spacer(), + Text( + DateTimeUtil.utc2iso(model.createdAt), + style: TextStyle( + color: Color(0xFF989898), + fontSize: 12, + ), + ), + ], + ), + ), + 11.sizeBoxH, + Text( + model.content ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Color(0xFF989898), + fontSize: 12, + ), + ), + ], + ), + ), + 8.sizeBoxW, + Container( + alignment: Alignment.bottomCenter, + height: 82, + child: NetworkImageLoader( + imageUrl: model.objCover ?? '', + height: 60, + width: 60, + borderRadius: 6, + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_drive/mine_drive_logic.dart b/lib/hj_page/mine/mine_drive/mine_drive_logic.dart new file mode 100644 index 0000000..6fefaa5 --- /dev/null +++ b/lib/hj_page/mine/mine_drive/mine_drive_logic.dart @@ -0,0 +1,25 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/mine/official_list_item_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; + +class MineDriveLogic extends GetxController { + /// null 表示未加载(loading 态),非 null 表示已加载(可能为空) + List? allList; + List groupList = []; + List officialList = []; + + @override + void onReady() { + super.onReady(); + loadData(); + } + + Future loadData() async { + final list = await MineService.getOfficialLists() ?? []; + allList = list; + // position == 2 归商务合作,其余归官方社群 + officialList = list.where((e) => e.position == 2).toList(); + groupList = list.where((e) => e.position != 2).toList(); + update(); + } +} diff --git a/lib/hj_page/mine/mine_drive/mine_drive_page.dart b/lib/hj_page/mine/mine_drive/mine_drive_page.dart new file mode 100644 index 0000000..552859a --- /dev/null +++ b/lib/hj_page/mine/mine_drive/mine_drive_page.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/hj_utils/text_util.dart'; + +import '../../../hj_model/mine/official_list_item_model.dart'; +import '../../../routers/jump_router.dart'; +import '../../../tools_base/loading/loading_center_widget.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import 'mine_drive_logic.dart'; + +class MineDrivePage extends StatelessWidget { + const MineDrivePage({super.key}); + + static const _titleStyle = + TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600); + static const _subtitleStyle = + TextStyle(color: Color(0xff525252), fontSize: 12); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('加群开车')), + body: GetBuilder( + init: MineDriveLogic(), + builder: (controller) { + if (controller.allList == null) return const LoadingCenterWidget(); + if (controller.allList!.isEmpty) { + return CErrorWidget(retryOnTap: controller.loadData); + } + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: CustomScrollView( + slivers: [ + if (controller.groupList.isNotEmpty) + _buildSection( + title: '官方社群', + subtitle: '一起看片一起分享心得', + list: controller.groupList), + if (controller.officialList.isNotEmpty) + _buildSection( + title: '商务合作', + subtitle: '代理合作/商务合作', + list: controller.officialList), + ], + ), + ); + }, + ), + ); + } + + /// 一个分组区块:标题 + 副标题 + 列表卡片 + Widget _buildSection({ + required String title, + required String subtitle, + required List list, + }) { + return SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 18.sizeBoxH, + Text(title, style: _titleStyle), + 6.sizeBoxH, + Text(subtitle, style: _subtitleStyle), + 12.sizeBoxH, + Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(9), + ), + child: Column(children: list.map(_buildItem).toList()), + ), + ], + ), + ); + } + + /// 单条社群/合作项 + Widget _buildItem(OfficialListItemModel item) { + return InkWell( + enableFeedback: false, + onTap: () => launchUrlToWeb(item.officialUrl ?? ''), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5), + height: 80, + alignment: Alignment.center, + child: Row( + children: [ + NetworkImageLoader( + imageUrl: item.officialImg ?? '', width: 40, height: 40), + 12.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item.officialName ?? '', + style: const TextStyle( + color: Colors.white, fontSize: 14, height: 1.1), + ), + if (TextUtil.isNotEmpty(item.officialDesc)) ...[ + 6.sizeBoxH, + Text( + item.officialDesc ?? '', + style: const TextStyle( + color: Color(0xff525252), fontSize: 12, height: 1.1), + ), + ], + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: const Text( + '立即加入', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_following/mine_follow_sub_logic.dart b/lib/hj_page/mine/mine_following/mine_follow_sub_logic.dart new file mode 100644 index 0000000..6404029 --- /dev/null +++ b/lib/hj_page/mine/mine_following/mine_follow_sub_logic.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/swipe_action_item.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../hj_model/mine/follow_user_list_model.dart'; +import 'mine_follow_sub_page.dart'; +import 'widget/follow_list_items.dart'; + +abstract class MineFollowSubLogic extends GetxController + with GetTickerProviderStateMixin { + final int? uid; + MineFollowSubLogic(this.uid); + + RxInt loadCount = 0.obs; + + final dataSource = []; + + RefreshController? refreshController; + int pageNumber = 1; + bool isLoading = true; + bool isGridStyle = false; + + @override + void onReady() { + super.onReady(); + fetchPageData(); + } + + @mustCallSuper + fetchPageData({bool isRefresh = true}) { + if (isRefresh) { + pageNumber = 1; + } + } + + Widget instanceChildItem(int index); +} + +class ActressController extends MineFollowSubLogic { + ActressController(super.uid); + + @override + fetchPageData({bool isRefresh = true}) async { + super.fetchPageData(isRefresh: isRefresh); + final retModel = await MineService.fetchCollectList( + 'actress', + page: pageNumber, + size: 10, + uid: uid, + ); + + isLoading = false; + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + retModel?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(retModel?.list ?? []); + pageNumber += 1; + update(); + } + + @override + Widget instanceChildItem(int index) { + return FollowActressItem(dataSource[index]); + } +} + +class BloggerController extends MineFollowSubLogic { + BloggerController(super.uid); + + @override + fetchPageData({bool isRefresh = true}) async { + super.fetchPageData(isRefresh: isRefresh); + final retModel = await CommonService.getFollowUsers( + pageNumber: pageNumber, + pageSize: 10, + uid: uid, + ); + + isLoading = false; + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + retModel?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(retModel?.list ?? []); + pageNumber += 1; + update(); + } + + @override + Widget instanceChildItem(int index) { + final model = dataSource[index] as FollowUserModel; + // 用 ObjectKey 而不是 ValueKey(uid):uid 是可空的,两条 uid 都为 null 就是重复 key, + // Flutter 会直接抛 Duplicate keys 白屏。绑对象身份天然唯一,且能让展开状态不串项 + return SwipeActionItem( + key: ObjectKey(model), + actionText: '取消关注', + onAction: () => _unfollow(model), + child: FollowBloggerItem(model), + ); + } + + /// 正在取关的项:按钮收起动画那 200ms 里还能再点一次,防重复请求 + final _unfollowing = {}; + + /// 左滑取关:成功才从列表摘掉(接口内部会 emit CollectStatusModel 同步其他页面) + Future _unfollow(FollowUserModel model) async { + if (!_unfollowing.add(model)) return; + try { + final ok = await MineService.getFollow(model.uid, false); + if (!ok) return showToast('取消关注失败,请重试'); + dataSource.remove(model); // 按对象移除,不用 index——异步期间列表可能已刷新 + update(); + } finally { + _unfollowing.remove(model); + } + } +} + +class TopicController extends MineFollowSubLogic { + TopicController(super.uid); + @override + bool get isGridStyle => true; + + @override + fetchPageData({bool isRefresh = true}) async { + super.fetchPageData(isRefresh: isRefresh); + final retModel = await MineService.fetchCollectList("tag", + page: pageNumber, size: 10, uid: uid); + isLoading = false; + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + retModel?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(retModel?.list ?? []); + pageNumber += 1; + update(); + } + + @override + Widget instanceChildItem(int index) { + return FollowTopicItem(dataSource[index]); + } +} + +MineFollowSubLogic instanceController(FollowType type, {int? uid}) { + switch (type) { + case FollowType.user: + return BloggerController(uid); + case FollowType.topic: + return TopicController(uid); + case FollowType.actress: + return ActressController(uid); + default: + throw 'type 没有定义'; + } +} diff --git a/lib/hj_page/mine/mine_following/mine_follow_sub_page.dart b/lib/hj_page/mine/mine_following/mine_follow_sub_page.dart new file mode 100644 index 0000000..fa7c483 --- /dev/null +++ b/lib/hj_page/mine/mine_following/mine_follow_sub_page.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../tools_base/loading/loading_center_widget.dart'; +import '../../../tools_base/refresh/pull_refresh.dart'; +import '../../../tools_base/widget/swipe_action_item.dart'; +import 'mine_follow_sub_logic.dart'; + +enum FollowType { + user, + topic, + actress; +} + +class MineFollowSubPage extends StatelessWidget { + final FollowType pageType; // 0 博主, 1 粉丝, 2 话题 + final int? uid; + + MineFollowSubPage({super.key, required this.pageType, this.uid}); + + @override + Widget build(BuildContext context) { + return Scaffold( + // 用默认 global:global:false 下 widget 销毁不会 Get.delete,onClose 永远不触发, + // 往 logic 里加 Timer/订阅就会静默泄漏。本页单实例(入口只在「我的」,链路不成环)故不加 tag; + // 将来若做成多 pageType 并存的 TabBarView,得按 pageType 补 tag,否则三个 tab 抢同一个实例 + body: GetBuilder( + init: instanceController(pageType, uid: uid), + builder: (logic) { + return pullYsRefresh( + onRefresh: (refreshController) => logic.fetchPageData(), + onLoading: (refreshController) => logic.fetchPageData(isRefresh: false), + onInit: (ctr) => logic.refreshController = ctr, + child: () { + if (logic.isLoading) return const LoadingCenterWidget(); + if (logic.dataSource.isEmpty) + return CErrorWidget( + retryOnTap: () { + logic.fetchPageData(); + }, + ); + if (logic.isGridStyle) + return GridView.builder( + padding: EdgeInsets.only(left: 16, right: 16, top: 12), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 5, + childAspectRatio: 165 / 143, + ), + itemCount: logic.dataSource.length, + itemBuilder: (BuildContext context, int index) { + return logic.instanceChildItem(index); + }, + ); + // 一滚动就收起左滑展开的那项,免得滑走了还留着个张开的 + return NotificationListener( + onNotification: (_) { + SwipeActionItem.closeOpened(); + return false; + }, + child: ListView.builder( + padding: EdgeInsets.only(top: 12), + itemCount: logic.dataSource.length, + itemBuilder: (BuildContext context, int index) { + return logic.instanceChildItem(index); + }, + ), + ); + }()); + }), + ); + } +} diff --git a/lib/hj_page/mine/mine_following/mine_following_view.dart b/lib/hj_page/mine/mine_following/mine_following_view.dart new file mode 100644 index 0000000..9f6df46 --- /dev/null +++ b/lib/hj_page/mine/mine_following/mine_following_view.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import 'mine_follow_sub_page.dart'; + +class MineFollowingPage extends StatelessWidget { + const MineFollowingPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('我的关注')), + body: MineFollowSubPage(pageType: FollowType.user), + ); + } +} diff --git a/lib/hj_page/mine/mine_following/widget/follow_list_items.dart b/lib/hj_page/mine/mine_following/widget/follow_list_items.dart new file mode 100644 index 0000000..71977c6 --- /dev/null +++ b/lib/hj_page/mine/mine_following/widget/follow_list_items.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/mine/follow_user_list_model.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../community/community_tag_page/community_tag_page.dart'; +import 'package:hgdj/extension/extensions.dart'; + +/// 关注列表 - 女优项 +class FollowActressItem extends StatefulWidget { + final FollowUserModel model; + const FollowActressItem(this.model, {super.key}); + + @override + State createState() => _FollowActressItemState(); +} + +class _FollowActressItemState extends State { + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: () { + // type:1 原跳女优主页 ActressMainPage(已下线),pushToPersonCenter 内 type==1 已 return null + // → 当前点击不跳转,待产品确认替代页(objcId 与用户中心 uid 非同一体系,不能直接跳 UserCenterPage) + pushToPersonCenter(widget.model.objcId, type: 1); + }, + child: Container( + margin: EdgeInsets.symmetric(horizontal: 18.w), + padding: EdgeInsets.symmetric(horizontal: 13.w, vertical: 16.h), + decoration: BoxDecoration( + color: Color(0xff242424), + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + child: Row( + children: [ + NetworkImageLoader( + imageUrl: widget.model.portrait ?? "", + width: 43, + height: 43, + borderRadius: 6, + ), + SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.model.name ?? "", + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + Text( + "粉丝: ${widget.model.fans?.countStr}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + color: Color(0xff666666), + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + GestureDetector( + onTap: () async { + final isFollow = widget.model.hasFollow ?? false; + final res = await MineService.postCollect( + widget.model.objcId, 'actresss', !isFollow); + if (res) { + widget.model.hasFollow = !isFollow; + setState(() {}); + } + }, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 11.w, vertical: 5.h), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .3), + borderRadius: BorderRadius.circular(40)), + child: Row( + children: [ + if (widget.model.hasFollow == true) ...[ + Image.asset('collect_red.png'.commonImgPath, width: 16.w), + 7.w.sizeBoxW, + ], + Text( + widget.model.hasFollow == true ? '已关注' : '关注', + style: TextStyle(color: Colors.white, fontSize: 14), + ) + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 关注列表 - 博主项 +class FollowBloggerItem extends StatefulWidget { + final FollowUserModel model; + const FollowBloggerItem(this.model, {super.key}); + + @override + State createState() => _FollowBloggerItemState(); +} + +class _FollowBloggerItemState extends State { + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: () { + pushToPersonCenter(widget.model.uid ?? 0); + }, + child: Container( + padding: EdgeInsets.only(bottom: 18, left: 16, right: 16), + child: Row( + children: [ + NetworkImageLoader( + imageUrl: widget.model.portrait ?? "", + width: 60, + height: 60, + borderRadius: 30, + ), + SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.model.name ?? "", + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 16, + color: Color(0xE5FFFFFF), + fontWeight: FontWeight.w500, + ), + ), + 6.sizeBoxH, + Row( + children: [ + Flexible( + child: Text( + '${widget.model.totalWorks ?? 0} 作品', + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 12, + color: Color(0x8CFFFFFF), + fontWeight: FontWeight.w400), + ), + ), + SizedBox(width: 12), + Flexible( + child: Text( + '${widget.model.fans?.countStr} 粉丝', + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 12, + color: Color(0x8CFFFFFF), + fontWeight: FontWeight.w400), + ), + ), + ], + ) + ], + ), + ), + SizedBox(width: 12), + Image.asset( + 'arrow_right_grey.webp'.commonImgPath, + color: Color(0xffDCDCDC), + width: 24, + ) + ], + ), + ), + ); + } +} + +/// 关注列表 - 话题项 +class FollowTopicItem extends StatefulWidget { + final TagsBean model; + const FollowTopicItem(this.model, {super.key}); + + @override + State createState() => _FollowTopicItemState(); +} + +class _FollowTopicItemState extends State { + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: () { + Get.to(() => CommunityTagDetailPage(model: widget.model)); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Stack( + alignment: Alignment.center, + children: [ + NetworkImageLoader( + imageUrl: widget.model.coverImg ?? "", + width: double.infinity, + height: double.infinity, + borderRadius: 8, + ), + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: .6), + borderRadius: BorderRadius.circular(8)), + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "#${widget.model.name}", + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + Text( + '${widget.model.videoCount.countStr}个帖子', + style: TextStyle(color: Color(0xffcccccc), fontSize: 10), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + ), + SizedBox(height: 12), + GestureDetector( + onTap: () async { + final isCollected = widget.model.hasCollected ?? false; + final res = await MineService.postCollect( + widget.model.id, "tag", !isCollected); + if (res) { + widget.model.hasCollected = !isCollected; + setState(() {}); + } + }, + child: Container( + width: 108, + height: 27, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Color(0xFFF68804), + borderRadius: BorderRadius.circular(6)), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.model.hasCollected == true ? '已关注' : '关注', + style: TextStyle(color: Colors.white, fontSize: 14), + ) + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_gesture_pwd/model/point_item.dart b/lib/hj_page/mine/mine_gesture_pwd/model/point_item.dart new file mode 100644 index 0000000..1ee26e4 --- /dev/null +++ b/lib/hj_page/mine/mine_gesture_pwd/model/point_item.dart @@ -0,0 +1,26 @@ +class PointItem { + double x; + double y; + int? index; + bool isSelected; + bool isError; + bool isFirstSelected; + double angle; + + PointItem({ + required this.x, + required this.y, + this.index, + this.isSelected = false, + this.isError = false, + this.isFirstSelected = false, + this.angle = double.infinity, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || other is PointItem && runtimeType == other.runtimeType && x == other.x && y == other.y; + + @override + int get hashCode => x.hashCode ^ y.hashCode; +} diff --git a/lib/hj_page/mine/mine_gesture_pwd/widget/gesture_password_widget.dart b/lib/hj_page/mine/mine_gesture_pwd/widget/gesture_password_widget.dart new file mode 100644 index 0000000..2015d93 --- /dev/null +++ b/lib/hj_page/mine/mine_gesture_pwd/widget/gesture_password_widget.dart @@ -0,0 +1,602 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../tools_base/debug_log.dart'; +import '../model/point_item.dart'; +import 'line_painter.dart'; + +/// 当点被选中时的回调函数 +typedef OnHitPoint = void Function(List result); + +/// 手势滑动结束时的回调函数 +/// [result] 已经选择的所有点的结果集 +typedef OnComplete = void Function(List result); + +class GesturePasswordWidget extends StatefulWidget with DiagnosticableTreeMixin { + /// GesturePasswordWidget 的 width 和 height. + final double size; + + ///用来判断点是否被选中的区域大小,值越大识别越精准. + final double identifySize; + + ///正常情况下展示的widget + final Widget? normalItem; + + ///选中情况下展示的widget + final Widget? selectedItem; + + /// 错误情况下展示的widget,只有设置了[minLength]或[answer]时才会生效, + /// 1)当[minLength]不为null时,如果选择的点的数量小于minLength,则展示[errorItem], + /// 比如设置了 minLength = 4,但是选择的点的结果集为 [0,1,3],共选择了3个点,小于4; + /// 2)当[answer]不为null时,如果选择的点的结果集和[answer]不相等,则展示[errorItem], + /// 比如 answer = [0,1,2,4,7],但是选择的点的结果集为[0,1,2,5,8],和answer不相等; + /// 另外,[errorItem]的展示时长由[completeWaitMilliseconds]控制。 + final Widget? errorItem; + + /// 当这个点被选中时要展示的widget,其展示时长由[hitShowMilliseconds]控制,达到展示时长 + /// 后继续展示[selectedItem]。 + final Widget? hitItem; + + ///正常情况下显示的箭头控件。 + ///跟随手势旋转时,x轴正方向为0度,所以如果你使用了箭头,确保箭头指向x轴正方向。 + final Widget? arrowItem; + + ///错误情况下显示的箭头控件,如果设置了[errorArrowItem],则必须设置[arrowItem], + ///否则[errorArrowItem]不会展示。 + ///跟随手势旋转时,x轴正方向为0度,所以如果你使用了箭头,确保箭头指向x轴正方向。 + final Widget? errorArrowItem; + + ///[arrowItem]和[errorArrowItem]在x轴上的偏移,原点在[normalItem]的中心。 + ///当 -1 < [arrowXAlign] < 1 时,[arrowItem]和[errorArrowItem]在[normalItem]范围内进行绘制; + ///当[arrowXAlign] > 1 或者[arrowXAlign] < -1时,在[normalItem]范围外进行绘制; + final double arrowXAlign; + + ///[arrowItem]和[errorArrowItem]在y轴上的偏移,原点在[normalItem]的中心。 + ///当 -1 < [arrowYAlign] < 1 时,[arrowItem]和[errorArrowItem]在[normalItem]范围内进行绘制; + ///当[arrowYAlign] > 1 或者[arrowYAlign] < -1时,在[normalItem]范围外进行绘制; + final double arrowYAlign; + + ///单行个数,总个数等于 singleLineCount * singleLineCount. + final int singleLineCount; + + ///GesturePasswordWidget的背景色,默认为 [Theme.of].[scaffoldBackgroundColor] + final Color? color; + + ///当点被选中时的回调函数 + final OnHitPoint? onHitPoint; + + ///手势滑动结束时的回调函数 + final OnComplete? onComplete; + + ///线的颜色 + final Color lineColor; + + ///错误场景下线的颜色,见[errorItem] + final Color errorLineColor; + + ///线的宽度 + final double lineWidth; + + /// 是否采用宽松策略,默认为true。 + /// 考虑这种情况:第一个点选中了 index = 0 的点,第二个点选中了 index = 6的点, + /// 此时index = 0,index = 3,index = 6这三个点在一条直线上, + /// 如果loose为true,输出为[0,3,6], + /// 如果loose为false,输出为[0,6]. + final bool loose; + + ///正确的结果,demo: [0, 1, 2, 4, 7] + final List? answer; + + ///最后选择的所有点及绘制的直线在屏幕上展示的时间,时间结束后,清除所有点,恢复到初始状态, + ///时间结束之前 GesturePasswordWidget 不再接受任何手势事件。 + final int completeWaitMilliseconds; + + /// 只是用来展示用,不能触摸 + final bool ignoring; + + ///见[hitItem] + final int hitShowMilliseconds; + + ///如果设置了此值,则长度不够时显示[errorItem]和[errorLineColor]. + final int? minLength; + + ///是否开启触觉反馈(命中点轻震、绘制错误较重震),默认开启 + final bool enableHaptic; + + GesturePasswordWidget({ + super.key, + this.size = 300.0, + this.identifySize = 50.0, + this.normalItem, + this.selectedItem, + this.errorItem, + this.hitItem, + this.arrowItem, + this.errorArrowItem, + this.arrowXAlign = 0.6, + this.arrowYAlign = 0.0, + this.singleLineCount = 3, + this.color, + this.ignoring = false, + this.onHitPoint, + this.onComplete, + this.lineColor = Colors.green, + this.errorLineColor = Colors.redAccent, + this.lineWidth = 2.0, + this.answer, + this.loose = true, + this.completeWaitMilliseconds = 300, + this.hitShowMilliseconds = 40, + this.minLength, + this.enableHaptic = true, + }) : assert(singleLineCount > 1, 'singLineCount must not be smaller than 1'), + assert(identifySize > 0), + assert(size > identifySize), + assert(!(errorArrowItem != null && arrowItem == null), 'when arrowItem == null, errorArrowItem will not be shown.'); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('size', size)); + properties.add(DoubleProperty('identifySize', identifySize)); + properties.add(DiagnosticsProperty('normalItem', normalItem)); + properties.add(DiagnosticsProperty('selectedItem', selectedItem)); + properties.add(DiagnosticsProperty('errorItem', errorItem)); + properties.add(DiagnosticsProperty('hitItem', hitItem)); + properties.add(DiagnosticsProperty('arrowItem', arrowItem)); + properties.add( + DiagnosticsProperty('errorArrowItem', errorArrowItem), + ); + properties.add(DoubleProperty('arrowXAlign', arrowXAlign)); + properties.add(DoubleProperty('arrowYAlign', arrowYAlign)); + properties.add(IntProperty('singleLineCount', singleLineCount)); + properties.add(ColorProperty('color', color)); + properties.add(DiagnosticsProperty('onHitPoint', onHitPoint)); + properties.add(DiagnosticsProperty('onComplete', onComplete)); + properties.add(ColorProperty('lineColor', lineColor)); + properties.add(ColorProperty('errorLineColor', errorLineColor)); + properties.add(IterableProperty('answer', answer)); + properties.add(DoubleProperty('lineWidth', lineWidth)); + properties.add(FlagProperty( + 'loose', + value: loose, + ifFalse: 'loose: false', + ifTrue: 'loose: true', + defaultValue: true, + )); + properties.add( + IntProperty('completeWaitMilliseconds', completeWaitMilliseconds), + ); + properties.add(IntProperty('hitShowMilliseconds', hitShowMilliseconds)); + properties.add(IntProperty('minLength', minLength)); + } + + @override + State createState() => _GesturePasswordWidgetState(); +} + +class _GesturePasswordWidgetState extends State { + late Point origin; + late int totalCount; + Point? lastPoint; + Widget? normalItem; + Widget? defaultNormalItem; + Widget? selectedItem; + Widget? defaultSelectedItem; + Widget? errorItem; + Widget? defaultErrorItem; + Color? lineColor; + + String tipText = '绘制解锁图形'; + bool _completing = false; // 完成动画期间锁定手势,避免动画未结束就开始下一笔 + + final points = []; + final linePoints = >[]; + final result = []; + final double defaultSize = 10.0; + + @override + void initState() { + super.initState(); + defaultNormalItem = Container( + width: defaultSize, + height: defaultSize, + decoration: BoxDecoration( + color: Colors.greenAccent, + borderRadius: BorderRadius.circular(50.0), + ), + ); + defaultSelectedItem = Container( + width: defaultSize, + height: defaultSize, + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(50.0), + ), + ); + defaultErrorItem = Container( + width: defaultSize, + height: defaultSize, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(50.0), + ), + ); + + if (widget.answer?.isNotEmpty ?? false) { + tipText = '验证解锁图形'; + } + + lineColor = widget.lineColor; + normalItem = widget.normalItem ?? defaultNormalItem; + selectedItem = widget.selectedItem ?? defaultSelectedItem; + errorItem = widget.errorItem ?? defaultErrorItem; + + totalCount = widget.singleLineCount * widget.singleLineCount; + origin = Point(widget.size * 0.5, widget.size * 0.5); + calculatePointPosition(); + } + + @override + void didUpdateWidget(GesturePasswordWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.lineColor != oldWidget.lineColor) { + lineColor = widget.lineColor; + } + + if (widget.normalItem != oldWidget.normalItem) { + normalItem = widget.normalItem ?? defaultNormalItem; + } + + if (widget.selectedItem != oldWidget.selectedItem) { + selectedItem = widget.selectedItem ?? defaultSelectedItem; + } + + if (widget.errorItem != oldWidget.errorItem) { + errorItem = widget.errorItem ?? defaultErrorItem; + } + + if (widget.singleLineCount != oldWidget.singleLineCount || + widget.size != oldWidget.size || + widget.identifySize != oldWidget.identifySize) { + totalCount = widget.singleLineCount * widget.singleLineCount; + origin = Point(widget.size * 0.5, widget.size * 0.5); + points.clear(); + calculatePointPosition(); + } + } + + @override + Widget build(BuildContext context) { + //预览小点的边长:每行 singleLineCount 个 10px 的点 + 之间 8px 间距。必须按 singleLineCount 算, + //写死值(原来是 56)比内容宽时 Wrap 默认贴左上,整块预览会偏左偏上,且换行数只对 3x3 成立 + final previewSize = 10.0 * widget.singleLineCount + 8 * (widget.singleLineCount - 1); + return Column( + children: [ + SizedBox( + width: previewSize, + height: previewSize, + child: Wrap( + runSpacing: 8, + spacing: 8, + children: points + .map((e) => Container( + width: 10, + height: 10, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + //选中态靠加粗边框填实心,边框画在 10x10 内部,不影响尺寸和对齐 + border: Border.all(width: e.isSelected ? 5 : 1.0, color: const Color(0xff6792ff)), + ), + )) + .toList(), + ), + ), + Container( + margin: EdgeInsets.symmetric(vertical: 20), + child: Text( + tipText, + style: TextStyle( + color: Colors.white, + ), + ), + ), + IgnorePointer( + ignoring: widget.ignoring || _completing, + child: Container( + color: widget.color ?? Theme.of(context).scaffoldBackgroundColor, + width: widget.size, + height: widget.size, + child: Stack( + children: createPointsWidget() + ..add( + GestureDetector( + onPanDown: handlePanDown, + onPanUpdate: handlePanUpdate, + onPanEnd: handlePanEnd, + onPanCancel: () { + handlePanEnd(null); + }, + child: CustomPaint( + painter: LinePainter( + points: linePoints, + lineColor: lineColor, + lineWidth: widget.lineWidth, + ), + willChange: true, + size: Size(widget.size, widget.size), + ), + ), + ), + ), + ), + ) + ], + ); + } + + //计算每个点的位置 + void calculatePointPosition() { + double initX = widget.identifySize * 0.5; + double initY = widget.identifySize * 0.5; + double gap = (widget.size - widget.identifySize) / (widget.singleLineCount - 1); + + for (int i = 0; i < totalCount; i++) { + double centerX = initX + i % widget.singleLineCount * gap; + double centerY = initY + i ~/ widget.singleLineCount * gap; + var point = PointItem(x: centerX, y: centerY, index: i); + //展示模式(ignoring)下预置 answer 命中的点为选中 + if (widget.ignoring && (widget.answer?.contains(i) ?? false)) { + point.isSelected = true; + } + points.add(point); + } + } + + //创建每个点的widget + List createPointsWidget() { + return points.map((p) { + double reference = 1 - (widget.identifySize / widget.size); + double x = (p.x - origin.x) / (widget.size * 0.5) / reference; + double y = (p.y - origin.y) / (widget.size * 0.5) / reference; + + Widget? child = normalItem; + if (p.isError) { + child = errorItem; + } else if (p.isFirstSelected) { + child = widget.hitItem; + } else if (p.isSelected) { + child = selectedItem; + } + + Widget? arrowItem = widget.arrowItem; + if (p.isError && widget.errorArrowItem != null) { + arrowItem = widget.errorArrowItem; + } + + return Align( + alignment: Alignment(x, y), + child: Container( + color: Colors.transparent, + width: widget.identifySize, + height: widget.identifySize, + alignment: Alignment.center, + child: widget.arrowItem == null || p.angle == double.infinity + ? child + : Transform.rotate( + angle: p.angle, + child: Stack( + alignment: AlignmentDirectional.center, + children: [ + child!, + Align( + alignment: Alignment( + widget.arrowXAlign, + widget.arrowYAlign, + ), + child: arrowItem, + ), + ], + ), + ), + ), + ); + }).toList(); + } + + void handlePanDown(DragDownDetails details) { + Point curPoint = Point(details.localPosition.dx, details.localPosition.dy); + final point = calculateHitPoint(curPoint); + if (point != null) { + if (!linePoints.contains(Point(point.x, point.y))) { + addPointToResult(point.index); + setState(() { + point.isSelected = true; + linePoints.add(Point(point.x, point.y)); + }); + } + } + } + + void handlePanUpdate(DragUpdateDetails details) { + Point curPoint = Point(details.localPosition.dx, details.localPosition.dy); + final hitPoint = calculateHitPoint(curPoint); + if (hitPoint != null) { + if (!linePoints.contains(Point(hitPoint.x, hitPoint.y))) { + final drawPoint = Point(hitPoint.x, hitPoint.y); + //宽松策略下,若三点共线则自动将中间的点设置为选中状态。 + if (widget.loose && linePoints.isNotEmpty) { + handleLooseCase(points[result.last], hitPoint); + } + + //处理箭头的角度展示 + if (widget.arrowItem != null) { + for (int i = 0; i < result.length - 1; i++) { + final p1 = Point(points[result[i]].x, points[result[i]].y); + final p2 = Point(points[result[i + 1]].x, points[result[i + 1]].y); + points[result[i]].angle = calculateAngle(p1, p2); + } + } + + if (result.isNotEmpty) { + final p1 = Point(points[result.last].x, points[result.last].y); + points[result.last].angle = calculateAngle(p1, Point(hitPoint.x, hitPoint.y)); + } + addPointToResult(hitPoint.index); + setState(() { + linePoints.remove(lastPoint); + hitPoint.isSelected = true; + linePoints.add(drawPoint); + }); + } + } else { + if (linePoints.isNotEmpty) { + if (widget.arrowItem != null) { + final p1 = Point(points[result.last].x, points[result.last].y); + points[result.last].angle = calculateAngle(p1, curPoint); + } + + setState(() { + linePoints.remove(lastPoint); + linePoints.add(curPoint); + }); + lastPoint = curPoint; + } + } + } + + void handlePanEnd(DragEndDetails? details) async { + if (result.isEmpty) { + return; + } + + if (!mounted) { + return; + } + + linePoints.removeLast(); + + if ((widget.answer != null && widget.answer!.join() != result.join()) || + (widget.minLength != null && widget.minLength! > result.length)) { + lineColor = widget.errorLineColor; + if (widget.minLength != null && widget.minLength! > result.length) { + tipText = '请至少链接4个点'; + } else { + tipText = '图形绘制不正确'; + } + for (int i = 0; i < result.length; i++) { + points[result[i]].isError = true; + } + if (widget.enableHaptic) HapticFeedback.mediumImpact(); //绘制错误:较重震动 + } + + //清除最后一个点的角度 + points[result.last].angle = double.infinity; + + if (!mounted) { + return; + } + + setState(() { + _completing = true; + }); + await Future.delayed(Duration( + milliseconds: widget.completeWaitMilliseconds, + )); + _completing = false; + lineColor = widget.lineColor; + widget.onComplete?.call(result); + debugLog('handlePanEnd answer=${widget.answer}, result = $result'); + if (!mounted) { + return; + } + setState(() { + for (final p in points) { + p.isSelected = false; + p.isError = false; + p.angle = double.infinity; + } + linePoints.clear(); + result.clear(); + }); + } + + //计算命中的点 + PointItem? calculateHitPoint(Point curPoint) { + for (int i = 0; i < points.length; i++) { + final p = Point(points[i].x, points[i].y); + if (p.distanceTo(curPoint) < widget.identifySize * 0.5) { + if (points[i].isSelected) { + return null; + } + return points[i]; + } + } + return null; + } + + void addPointToResult(int? index) { + if (widget.enableHaptic) HapticFeedback.selectionClick(); //每命中一个点轻震,提升手感 + result.add(index ?? 0); + widget.onHitPoint?.call(result); + + if (widget.hitItem != null) { + setState(() { + points[index!].isFirstSelected = true; + }); + Future.delayed(Duration(milliseconds: widget.hitShowMilliseconds), () { + if (!mounted) return; + setState(() { + points[index!].isFirstSelected = false; + }); + }); + } + } + + //根据海伦公式计算三角形面积,面积为0时视为三点共线。如果这个点还在共线的中间, + //则将其设置为选中状态,并将其添加到result中。 + void handleLooseCase(PointItem pre, PointItem next) { + List midItems = []; + for (final item in points) { + if (item != pre && item != next && item.isSelected == false) { + final itemDrawPoint = Point(item.x, item.y); + final preDrawPoint = Point(pre.x, pre.y); + final nextDrawPoint = Point(next.x, next.y); + double a = itemDrawPoint.distanceTo(preDrawPoint); + double b = itemDrawPoint.distanceTo(nextDrawPoint); + double c = preDrawPoint.distanceTo(nextDrawPoint); + double p = (a + b + c) * 0.5; + double area = p * (p - a) * (p - b) * (p - c); + + double halfDistance = c * 0.5; + Point mid = Point( + (pre.x + next.x) * 0.5, + (pre.y + next.y) * 0.5, + ); + + if (area - 0.5 <= 0 && itemDrawPoint.distanceTo(mid) < halfDistance) { + item.isSelected = true; + midItems.add(item.index); + } + } + } + + if (next.index! > pre.index!) { + midItems.sort((a, b) => a! - b!); + } else { + midItems.sort((a, b) => b! - a!); + } + + for (final index in midItems) { + addPointToResult(index); + } + } + + //计算两点之间连线和 x 轴的夹角,返回弧度 + double calculateAngle(Point p1, Point p2) { + return atan2((p2.y - p1.y), (p2.x - p1.x)); //弧度 + } +} diff --git a/lib/hj_page/mine/mine_gesture_pwd/widget/line_painter.dart b/lib/hj_page/mine/mine_gesture_pwd/widget/line_painter.dart new file mode 100644 index 0000000..29da02e --- /dev/null +++ b/lib/hj_page/mine/mine_gesture_pwd/widget/line_painter.dart @@ -0,0 +1,38 @@ +import 'dart:math'; + +import 'package:flutter/widgets.dart'; + +class LinePainter extends CustomPainter { + final List? points; + final Color? lineColor; + final double? lineWidth; + + LinePainter({ + this.points, + this.lineColor, + this.lineWidth, + }); + + @override + void paint(Canvas canvas, Size size) { + if (points != null && points!.length > 1) { + Paint paint = Paint(); + paint.strokeWidth = lineWidth!; + paint.color = lineColor!; + paint.isAntiAlias = true; + paint.style = PaintingStyle.fill; + paint.strokeCap = StrokeCap.round; + + for (int i = 0; i < points!.length - 1; i++) { + canvas.drawLine( + Offset(points![i].x as double, points![i].y as double), + Offset(points![i + 1].x as double, points![i + 1].y as double), + paint, + ); + } + } + } + + @override + bool shouldRepaint(LinePainter oldDelegate) => true; +} diff --git a/lib/hj_page/mine/mine_post/mine_publish_main_logic.dart b/lib/hj_page/mine/mine_post/mine_publish_main_logic.dart new file mode 100644 index 0000000..3afcbf4 --- /dev/null +++ b/lib/hj_page/mine/mine_post/mine_publish_main_logic.dart @@ -0,0 +1,66 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'mine_publish_sub_logic.dart'; + +class MinePublishLogic extends GetxController { + MinePublishLogic(); + + List checkedList = []; + + List dataSource = []; + + void addChecked(VideoModel videoModel) { + checkedList.add(videoModel); + update(); + } + + void removeChecked(VideoModel videoModel) { + checkedList.remove(videoModel); + update(); + } + + void clearChecked() { + checkedList.clear(); + update(); + } + + bool get isAllChecked { + if (dataSource.isEmpty || checkedList.isEmpty) { + return false; + } + return checkedList.length == dataSource.length; + } + + bool get isEmptyChecked { + return checkedList.isEmpty; + } + + void selectAllOrNot() { + if (isAllChecked) { + checkedList.clear(); + } else { + checkedList.clear(); + checkedList.addAll(dataSource); + } + update(); + } + + void delete() async { + if (checkedList.isEmpty) { + showToast('请选择要删除的项'); + return; + } + String resultMsg = await MineService.deletePublishes( + ids: checkedList.map((e) => e.id ?? '').toList()); + if (resultMsg.isEmpty) { + checkedList.clear(); + update(); + Get.find(tag: '2').loadData(isRefresh: true); + showToast('删除成功'); + } else { + showToast(resultMsg); + } + } +} diff --git a/lib/hj_page/mine/mine_post/mine_publish_main_page.dart b/lib/hj_page/mine/mine_post/mine_publish_main_page.dart new file mode 100644 index 0000000..bf6bc87 --- /dev/null +++ b/lib/hj_page/mine/mine_post/mine_publish_main_page.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/user/wallet_model.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../hj_utils/widget_util.dart'; +import '../../home/widget/publist_entry_alert.dart'; +import '../make_money/mine_withdrawal_record_page.dart'; +import '../make_money/withdrawal_page.dart'; +import 'mine_publish_main_logic.dart'; +import 'mine_publish_sub_logic.dart'; +import 'mine_publish_sub_page.dart'; + +///我发布的帖子 +class MinePublishPostPage extends StatefulWidget { + const MinePublishPostPage({super.key}); + + @override + State createState() { + return _MinePublishPostPageState(); + } +} + +class _MinePublishPostPageState extends State + with TickerProviderStateMixin { + final tabs = ['已发布', '待审核', '未通过']; + WalletModel? get wallet => globalStore.wallet; + late final tabCtr = TabController(length: tabs.length, vsync: this); + + bool inEdit = false; + + @override + void initState() { + super.initState(); + tabCtr.addListener(_onTabChanged); + } + + @override + void dispose() { + tabCtr.removeListener(_onTabChanged); + tabCtr.dispose(); + super.dispose(); + } + + void _onTabChanged() { + setState(() {}); + } + + void _setSelectedTabDatas() { + final logic = Get.find(); + if (tabCtr.index == 2) { + logic.dataSource = List.from( + Get.find(tag: '2').dataList ?? []); + } else { + logic.dataSource = []; + } + logic.update(); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MinePublishLogic(), + builder: (controller) { + return Scaffold( + appBar: AppBar( + title: Text("创作中心", + style: textStyle(18, Color(0xE5FFFFFF), FontWeight.w600)), + actions: [ + Visibility( + visible: tabCtr.index == 2, + child: TextButton( + onPressed: () { + if (inEdit) { + controller.clearChecked(); + } else { + _setSelectedTabDatas(); + } + setState(() => inEdit = !inEdit); + }, + child: Text( + inEdit ? '完成' : '编辑', + style: TextStyle( + color: Color(0xFF989898), + fontSize: 12, + ), + ), + ), + ) + ], + ), + body: Stack( + children: [ + Column( + children: [ + Consumer( + builder: (context, store, child) { + return Container( + margin: EdgeInsets.symmetric( + horizontal: 16, vertical: 18), + padding: EdgeInsets.fromLTRB(0, 14, 0, 22), + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + color: Colors.white.withValues(alpha: .05), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + _buildItem( + '收益余额', + '${wallet?.income}', + '立即提现', + () => Get.to(WithdrawalPage()), + ), + _buildItem( + '累计收益', + '${wallet?.vidIncome}', + '业绩明细', + () => + Get.to(RecordsPage(RecordType.income)), + ), + ], + ), + ], + ), + ); + }, + ), + _buildTabbar(), + Expanded(child: _buildContent()) + ], + ), + if (!inEdit) + Positioned( + right: 20, + bottom: 50, + child: PublishButton( + entry: PublishEntry.community, + ), + ), + if (inEdit) + Positioned( + left: 0, + right: 0, + height: 62, + bottom: MediaQuery.of(context).viewInsets.bottom, + child: Container( + margin: EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: Color(0xFF303030), + borderRadius: BorderRadius.circular(3), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onTap: () { + controller.selectAllOrNot(); + }, + child: Container( + width: 112, + height: 24, + alignment: Alignment.center, + child: Text( + controller.isAllChecked ? '取消全选' : '全选', + style: TextStyle( + color: Color(0x99FFFFFF), fontSize: 16), + ), + ), + ), + Container( + width: 1, + height: 20, + color: Color(0x1AFFFFFF), + ), + GestureDetector( + onTap: controller.isEmptyChecked + ? null + : () { + controller.delete(); + }, + child: Container( + width: 112, + height: 38, + decoration: BoxDecoration( + color: Color(0xFFF52C56).withValues( + alpha: controller.isEmptyChecked ? 0.5 : 1), + borderRadius: BorderRadius.circular(3), + ), + alignment: Alignment.center, + child: Text( + '删除(${controller.checkedList.length})', + style: TextStyle( + color: Colors.white.withValues( + alpha: controller.isEmptyChecked + ? 0.5 + : 1), + fontSize: 16), + ), + ), + ), + ], + ), + ), + ), + ], + )); + }, + ); + } + + _buildTabbar() { + return TabBar( + padding: const EdgeInsets.only(left: 12), + tabAlignment: TabAlignment.fill, + controller: tabCtr, + onTap: (index) { + if (index != 2 && inEdit) { + // 如果切换到非"未通过"标签页,强制退出编辑模式 + setState(() { + inEdit = false; + Get.find().clearChecked(); + }); + } + }, + tabs: List.generate( + tabs.length, + (index) => Padding( + padding: const EdgeInsets.only(top: 12, bottom: 4), + child: FittedBox( + child: Text(tabs[index]), + ), + )), + isScrollable: false, + labelColor: Colors.white.withValues(alpha: .9), + labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + unselectedLabelColor: Colors.white.withValues(alpha: 0.35), + unselectedLabelStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + indicator: CustomIndicator( + width: 13, + height: 3, + borderRadius: BorderRadius.circular(2), + isGradient: true, + ), + ); + } + + _buildItem( + String title, String count, String actionTitle, Function() action) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title, + style: TextStyle( + color: Colors.white.withValues(alpha: .7), + fontWeight: FontWeight.w500, + fontSize: 14), + ), + 6.sizeBoxH, + Text( + count, + style: TextStyle( + color: Colors.white, fontWeight: FontWeight.w500, fontSize: 24), + ), + 6.sizeBoxH, + GestureDetector( + onTap: () => action.call(), + child: Container( + width: 72, + height: 25, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(13.5)), + color: Color(0xffF68804), + ), + child: Text( + actionTitle, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 12.0), + ), + ), + ), + ], + ); + } + + // TabBarView 内容(外层调用处 line 134 已用 Expanded 包裹,这里不再套 Expanded,否则双 Expanded 冲突) + Widget _buildContent() { + return TabBarView( + controller: tabCtr, + physics: inEdit && tabCtr.index == 2 + ? const NeverScrollableScrollPhysics() + : null, + children: [ + MinePublishSubPage(status: 1, inEdit: false).keepAlive, + MinePublishSubPage(status: 0, inEdit: false).keepAlive, + MinePublishSubPage(status: 2, inEdit: inEdit).keepAlive, + ], + ); + } +} diff --git a/lib/hj_page/mine/mine_post/mine_publish_sub_logic.dart b/lib/hj_page/mine/mine_post/mine_publish_sub_logic.dart new file mode 100644 index 0000000..1c48cf9 --- /dev/null +++ b/lib/hj_page/mine/mine_post/mine_publish_sub_logic.dart @@ -0,0 +1,23 @@ +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +class MinePublishSubLogic extends ListBaseLogic { + final int status; + + MinePublishSubLogic({required this.status}); + + @override + void onReady() { + super.onReady(); + loadData(); + } + + void loadData({bool isRefresh = true}) => + fetchData(isRefresh: isRefresh, fetch: _fetch); + + Future<(List?, bool)> _fetch(int page) async { + final res = await MineService.fetchPublishes(page: page, status: status); + return (res.list, res.hasNext ?? false); + } +} diff --git a/lib/hj_page/mine/mine_post/mine_publish_sub_page.dart b/lib/hj_page/mine/mine_post/mine_publish_sub_page.dart new file mode 100644 index 0000000..39040a6 --- /dev/null +++ b/lib/hj_page/mine/mine_post/mine_publish_sub_page.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../../community/widget/community_post_widget.dart'; +import 'mine_publish_main_logic.dart'; +import 'mine_publish_sub_logic.dart'; + +typedef OnItemCheckCallback = void Function( + VideoModel videoModel, bool isChecked); + +class MinePublishSubPage extends StatelessWidget { + final int status; + final bool inEdit; + final OnItemCheckCallback? onItemCheck; + const MinePublishSubPage( + {super.key, this.status = 0, this.inEdit = false, this.onItemCheck}); + + @override + Widget build(BuildContext context) { + final logic = Get.find(); + + return GetBuilder( + init: MinePublishSubLogic(status: status), + tag: status.toString(), + builder: (controller) { + return pullYsRefresh( + onInit: (refr) => controller.refreshCtr = refr, + onRefresh: (refr) => controller.loadData(), + onLoading: (refr) => controller.loadData(isRefresh: false), + child: () { + if (controller.isLoading) return LoadingCenterWidget(); + if (controller.isEmptyData) return CErrorWidget(); + final list = controller.dataList!; + return ListView.builder( + itemCount: list.length, + itemBuilder: (BuildContext context, int index) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: CommunityPostWidget( + videoModel: list[index], + isMyPublish: true, + showCheck: inEdit, + isChecked: logic.checkedList.contains(list[index]), + onItemCheck: (videoModel, isChecked) { + if (isChecked) { + logic.addChecked(videoModel); + } else { + logic.removeChecked(videoModel); + } + }, + ), + ); + }, + ); + }(), + ); + }, + ); + } +} diff --git a/lib/hj_page/mine/mine_profit/bank_card_home_page.dart b/lib/hj_page/mine/mine_profit/bank_card_home_page.dart new file mode 100644 index 0000000..9bdc104 --- /dev/null +++ b/lib/hj_page/mine/mine_profit/bank_card_home_page.dart @@ -0,0 +1,312 @@ +import 'dart:convert' as convert; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; +import '../../../../hj_utils/light_model.dart'; +import '../../../../hj_utils/store_keys.dart'; +import '../../../../tools_base/refresh/pull_refresh.dart'; +import '../../../hj_utils/widget_util.dart'; +import '../../../tools_base/loading/loading_alert_widget.dart'; +import '../../../tools_base/loading/loading_center_widget.dart'; +import 'model/alipay_bank_list_model.dart'; +import 'widget/add_bank_dialog.dart'; + +///银行卡管理 +class BankCardHomePage extends StatefulWidget { + final AccountInfoModel? selectModel; + BankCardHomePage({super.key, this.selectModel}); + + @override + State createState() { + return _BankCardHomePageState(); + } +} + +class _BankCardHomePageState extends State { + bool isEdit = false; + + bool loading = true; + + List aliList = []; + + AccountInfoModel? selectItem; + List deleteList = []; + RefreshController? refreshCtr; + + @override + void initState() { + super.initState(); + _getAliListData(); + } + + void _onDeleteAccount() async { + if (deleteList.isEmpty) return; + var isDelete = await CommonAlert.show(content: "是否确认删除账号"); + if (!isDelete) return; + LoadingAlertWidget.show(); + bool result = false; + for (var model in deleteList) { + result = await MineService.bankCardDelete(id: model.id); + if (result) { + var result = await lightKV.getString(StoreKeys.LAST_A_ACCOUNT); + if (result != null) { + AccountInfoModel listBean = + AccountInfoModel.fromMap(convert.jsonDecode(result)); + if (model.id == listBean.id) { + lightKV.setString(StoreKeys.LAST_A_ACCOUNT, ""); + } + } + aliList.remove(model); + setState(() {}); + } + } + if (result) { + showToast("删除成功"); + isEdit = false; + if (mounted) setState(() {}); + } + LoadingAlertWidget.cancel(); + } + + void _getAliListData() async { + final model = await MineService.getBankCards(); + aliList.clear(); + aliList.addAll(model?.list ?? []); + refreshCtr?.refreshCompleted(); + if (aliList.isNotEmpty == true) { + if (widget.selectModel != null) { + selectItem = widget.selectModel; + } else { + // selectItem = aliList.firstOrNull; + } + } + loading = false; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text('选择银行卡', + style: textStyle(18, Color(0xE5FFFFFF), FontWeight.w600)), + leading: BackButton( + onPressed: () => Get.back(result: selectItem), + ), + actions: [ + InkWell( + enableFeedback: false, + onTap: () { + isEdit = !isEdit; + if (isEdit) { + deleteList.clear(); + } + setState(() {}); + }, + child: Container( + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Text( + isEdit ? "完成" : "编辑", + style: TextStyle( + color: isEdit ? AppColors.actionRed : Color(0x8CFFFFFF), + fontSize: 12, + ), + ), + ), + ), + 18.sizeBoxW, + ], + ), + body: Stack( + fit: StackFit.expand, + children: [ + Column( + children: [ + Expanded(child: () { + if (loading) return LoadingCenterWidget(); + if (aliList.isEmpty == true) return CErrorWidget(); + return Container( + margin: EdgeInsets.fromLTRB(16, 12, 16, 0), + child: pullYsRefresh( + onInit: (ctr) => refreshCtr = ctr, + enablePullUp: false, + onRefresh: (ctr) => _getAliListData(), + child: ListView.builder( + padding: EdgeInsets.only(top: 10, bottom: 80), + shrinkWrap: true, + itemCount: aliList.length ?? 0, + itemBuilder: (BuildContext context, int index) { + return _buildCardView(index, aliList[index]); + }, + ), + ), + ); + }()), + _buildCardAddView(), + ], + ), + if (isEdit) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: GestureDetector( + onTap: () => _onDeleteAccount(), + child: Container( + margin: EdgeInsets.symmetric(horizontal: 35, vertical: 20), + height: 44, + decoration: BoxDecoration( + color: Color(0xffF52C56), + borderRadius: const BorderRadius.all(Radius.circular(3)), + ), + child: Center( + child: Text( + "删除", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCardView(int index, AccountInfoModel? item) { + bool isSelected = false; + if (isEdit) { + if (deleteList.contains(item) == true) { + isSelected = true; + } + } else { + isSelected = selectItem?.id == item?.id; + } + return InkWell( + enableFeedback: false, + onTap: () { + if (isEdit) { + if (deleteList.contains(item) == true) { + deleteList.remove(item); + } else { + deleteList.add(item!); + } + } else { + Get.back(result: item); + } + setState(() {}); + }, + child: Container( + margin: EdgeInsets.only(bottom: 12), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 18), + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(9)), + color: const Color(0x0DFFFFFF)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + item?.getBankName() ?? "", + style: const TextStyle( + color: const Color(0xE5ffffff), + fontWeight: FontWeight.w500, + fontSize: 16.0), + ), + 9.sizeBoxW, + Text( + item?.actName ?? "", + style: const TextStyle( + color: const Color(0xE5ffffff), + fontWeight: FontWeight.w400, + fontSize: 12.0), + ) + ], + ), + 6.sizeBoxH, + Text("卡号:${item?.act}", + style: const TextStyle( + color: const Color(0xffffffff), + fontWeight: FontWeight.w400, + fontSize: 12.0)) + ], + ), + ), + Stack( + alignment: Alignment.center, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: isSelected ? Colors.white : Colors.transparent, + shape: BoxShape.circle, + ), + ), + Icon( + isSelected + ? CupertinoIcons.checkmark_circle_fill + : CupertinoIcons.circle, + color: isSelected ? Color(0xffE1351F) : Color(0xff999999), + ), + ], + ), + ], + ), + ), + ); + } + + //添加银行卡 + _buildCardAddView() { + return InkWell( + enableFeedback: false, + onTap: () async { + final res = await Get.dialog(AddBankDialog(), + barrierColor: Colors.black.withValues(alpha: .7)); + if (res != null) { + _getAliListData(); + } + }, + child: Container( + margin: EdgeInsets.symmetric(horizontal: 35, vertical: 20), + height: 44, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: const BorderRadius.all(Radius.circular(3)), + ), + child: Center( + child: Text( + "添加银行卡", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_profit/model/alipay_bank_list_model.dart b/lib/hj_page/mine/mine_profit/model/alipay_bank_list_model.dart new file mode 100644 index 0000000..3a1642a --- /dev/null +++ b/lib/hj_page/mine/mine_profit/model/alipay_bank_list_model.dart @@ -0,0 +1,53 @@ +import 'bankcard_info.dart'; + +class ApBankListModel { + List? list; + + static ApBankListModel fromJson(Map? map) { + ApBankListModel apBankListModel = ApBankListModel(); + if (map == null) return apBankListModel; + if (map['list'] is List) { + apBankListModel.list = (map['list'] as List).map((o) => AccountInfoModel.fromMap(o)).toList(); + } + return apBankListModel; + } +} + +class AccountInfoModel { + String? id; + String? actName; + String? act; + String? bankCode; + String? cardType; + + static AccountInfoModel fromMap(Map? map) { + AccountInfoModel listBean = AccountInfoModel(); + if (map == null) return AccountInfoModel(); + listBean.id = map['id']; + listBean.actName = map['actName']; + listBean.act = map['act']; + listBean.bankCode = map['bankCode']; + listBean.cardType = map['cardType']; + return listBean; + } + + ///获取银行名称 + String? getBankName() { + if (bankCode?.isEmpty == true) { + return ""; + } + BankCardModel? model = BankcardInfo().getBankInfoMap(bankCode!); + if (model == null) { + return BankcardInfo().getBankName(bankCode!); + } + return model.bankName ?? ""; + } +} + +class BankCardModel { + String? bankLogoId; + String? bankName; + String? bankCardId; + + BankCardModel(this.bankLogoId, this.bankName, this.bankCardId); +} diff --git a/lib/hj_page/mine/mine_profit/model/bankcard_info.dart b/lib/hj_page/mine/mine_profit/model/bankcard_info.dart new file mode 100644 index 0000000..fd88869 --- /dev/null +++ b/lib/hj_page/mine/mine_profit/model/bankcard_info.dart @@ -0,0 +1,201 @@ + + +import 'alipay_bank_list_model.dart'; + +class BankcardInfo { + + + static final BankcardInfo _instance = BankcardInfo._internal(); + + factory BankcardInfo() => _instance; + + BankcardInfo._internal(); + + var bankName = ['招商银行', '中国银行', '农业银行', '交通银行', '建设银行', '民生银行', '中信银行', '光大银行', '华夏银行', '工商银行', '兴业银行', '国家开发银行']; + var bankCardId = ['CMB', 'BOC', 'ABC', 'COMM', 'CCB', 'CMBC', 'CITIC', 'CEB', 'HXBANK', 'ICBC', 'CIB', 'CDB']; + + BankCardModel? getBankInfoMap(String bankId) { + Map map = Map(); + for (int i = 0; i < bankCardId.length; i++) { + BankCardModel model = new BankCardModel(null, bankName[i], bankCardId[i]); + map[bankCardId[i]] = model; + } + bool contains = map.containsKey(bankId); + if (contains) { + return map[bankId]; + } + return null; + } + + ///支付宝支持银行 + String? getBankName(String bankId) { + Map map = Map(); + map["SRCB"] = "深圳农村商业银行"; + map["BGB"] = "广西北部湾银行"; + map["SHRCB"] = "上海农村商业银行"; + map["BJBANK"] = "北京银行"; + map["WHCCB"] = "威海市商业银行"; + map["BOZK"] = "周口银行"; + map["KORLABANK"] = "库尔勒市商业银行"; + map["SPABANK"] = "平安银行"; + map["SDEB"] = "顺德农商银行"; + map["HURCB"] = "湖北省农村信用社"; + map["WRCB"] = "无锡农村商业银行"; + map["BOCY"] = "朝阳银行"; + map["CZBANK"] = "浙商银行"; + map["HDBANK"] = "邯郸银行"; + map["BOC"] = "中国银行"; + map["BOD"] = "东莞银行"; + map["CCB"] = "中国建设银行"; + map["ZYCBANK"] = "遵义市商业银行"; + map["SXCB"] = "绍兴银行"; + map["GZRCU"] = "贵州省农村信用社"; + map["ZJKCCB"] = "张家口市商业银行"; + map["BOJZ"] = "锦州银行"; + map["BOP"] = "平顶山银行"; + map["HKB"] = "汉口银行"; + map["SPDB"] = "上海浦东发展银行"; + map["NXRCU"] = "宁夏黄河农村商业银行"; + map["NYNB"] = "广东南粤银行"; + map["GRCB"] = "广州农商银行"; + map["BOSZ"] = "苏州银行"; + map["HZCB"] = "杭州银行"; + map["HSBK"] = "衡水银行"; + map["HBC"] = "湖北银行"; + map["JXBANK"] = "嘉兴银行"; + map["HRXJB"] = "华融湘江银行"; + map["BODD"] = "丹东银行"; + map["AYCB"] = "安阳银行"; + map["EGBANK"] = "恒丰银行"; + map["CDB"] = "国家开发银行"; + map["TCRCB"] = "江苏太仓农村商业银行"; + map["NJCB"] = "南京银行"; + map["ZZBANK"] = "郑州银行"; + map["DYCB"] = "德阳商业银行"; + map["YBCCB"] = "宜宾市商业银行"; + map["SCRCU"] = "四川省农村信用"; + map["KLB"] = "昆仑银行"; + map["LSBANK"] = "莱商银行"; + map["YDRCB"] = "尧都农商行"; + map["CCQTGB"] = "重庆三峡银行"; + map["FDB"] = "富滇银行"; + map["JSRCU"] = "江苏省农村信用联合社"; + map["JNBANK"] = "济宁银行"; + map["CMB"] = "招商银行"; + map["JINCHB"] = "晋城银行JCBANK"; + map["FXCB"] = "阜新银行"; + map["WHRCB"] = "武汉农村商业银行"; + map["HBYCBANK"] = "湖北银行宜昌分行"; + map["TZCB"] = "台州银行"; + map["TACCB"] = "泰安市商业银行"; + map["XCYH"] = "许昌银行"; + map["CEB"] = "中国光大银行"; + map["NXBANK"] = "宁夏银行"; + map["HSBANK"] = "徽商银行"; + map["JJBANK"] = "九江银行"; + map["NHQS"] = "农信银清算中心"; + map["MTBANK"] = "浙江民泰商业银行"; + map["LANGFB"] = "廊坊银行"; + map["ASCB"] = "鞍山银行"; + map["KSRB"] = "昆山农村商业银行"; + map["YXCCB"] = "玉溪市商业银行"; + map["DLB"] = "大连银行"; + map["DRCBCL"] = "东莞农村商业银行"; + map["GCB"] = "广州银行"; + map["NBBANK"] = "宁波银行"; + map["BOYK"] = "营口银行"; + map["SXRCCU"] = "陕西信合"; + map["GLBANK"] = "桂林银行"; + map["BOQH"] = "青海银行"; + map["CDRCB"] = "成都农商银行"; + map["QDCCB"] = "青岛银行"; + map["HKBEA"] = "东亚银行"; + map["HBHSBANK"] = "湖北银行黄石分行"; + map["WZCB"] = "温州银行"; + map["TRCB"] = "天津农商银行"; + map["QLBANK"] = "齐鲁银行"; + map["GDRCC"] = "广东省农村信用社联合社"; + map["ZJTLCB"] = "浙江泰隆商业银行"; + map["GZB"] = "赣州银行"; + map["GYCB"] = "贵阳市商业银行"; + map["CQBANK"] = "重庆银行"; + map["DAQINGB"] = "龙江银行"; + map["CGNB"] = "南充市商业银行"; + map["SCCB"] = "三门峡银行"; + map["CSRCB"] = "常熟农村商业银行"; + map["SHBANK"] = "上海银行"; + map["JLBANK"] = "吉林银行"; + map["CZRCB"] = "常州农村信用联社"; + map["BANKWF"] = "潍坊银行"; + map["ZRCBANK"] = "张家港农村商业银行"; + map["FJHXBC"] = "福建海峡银行"; + map["ZJNX"] = "浙江省农村信用社联合社"; + map["LZYH"] = "兰州银行"; + map["JSB"] = "晋商银行"; + map["BOHAIB"] = "渤海银行"; + map["CZCB"] = "浙江稠州商业银行"; + map["YQCCB"] = "阳泉银行"; + map["SJBANK"] = "盛京银行"; + map["XABANK"] = "西安银行"; + map["BSB"] = "包商银行"; + map["JSBANK"] = "江苏银行"; + map["FSCB"] = "抚顺银行"; + map["HNRCU"] = "河南省农村信用"; + map["COMM"] = "交通银行"; + map["CITIC"] = "中信银行"; + map["XTB"] = "邢台银行"; + map["HXBANK"] = "华夏银行"; + map["HNRCC"] = "湖南省农村信用社"; + map["DYCCB"] = "东营市商业银行"; + map["ORBANK"] = "鄂尔多斯银行"; + map["BJRCB"] = "北京农村商业银行"; + map["XYBANK"] = "信阳银行"; + map["ZGCCB"] = "自贡市商业银行"; + map["CDCB"] = "成都银行"; + map["HANABANK"] = "韩亚银行"; + map["CMBC"] = "中国民生银行"; + map["LYBANK"] = "洛阳银行"; + map["GDB"] = "广东发展银行"; + map["ZBCB"] = "齐商银行"; + map["CBKF"] = "开封市商业银行"; + map["H3CB"] = "内蒙古银行"; + map["CIB"] = "兴业银行"; + map["CRCBANK"] = "重庆农村商业银行"; + map["SZSBK"] = "石嘴山银行"; + map["DZBANK"] = "德州银行"; + map["SRBANK"] = "上饶银行"; + map["LSCCB"] = "乐山市商业银行"; + map["JXRCU"] = "江西省农村信用"; + map["ICBC"] = "中国工商银行"; + map["JZBANK"] = "晋中市商业银行"; + map["HZCCB"] = "湖州市商业银行"; + map["NHB"] = "南海农村信用联社"; + map["XXBANK"] = "新乡银行"; + map["JRCB"] = "江苏江阴农村商业银行"; + map["YNRCC"] = "云南省农村信用社"; + map["ABC"] = "中国农业银行"; + map["GXRCU"] = "广西省农村信用"; + map["PSBC"] = "中国邮政储蓄银行"; + map["BZMD"] = "驻马店银行"; + map["ARCU"] = "安徽省农村信用社"; + map["GSRCU"] = "甘肃省农村信用"; + map["LYCB"] = "辽阳市商业银行"; + map["JLRCU"] = "吉林农信"; + map["URMQCCB"] = "乌鲁木齐市商业银行"; + map["XLBANK"] = "中山小榄村镇银行"; + map["CSCB"] = "长沙银行"; + map["JHBANK"] = "金华银行"; + map["BHB"] = "河北银行"; + map["NBYZ"] = "鄞州银行"; + map["LSBC"] = "临商银行"; + map["BOCD"] = "承德银行"; + map["SDRCU"] = "山东农信"; + map["NCB"] = "南昌银行"; + map["TCCB"] = "天津银行"; + map["WJRCB"] = "吴江农商银行"; + map["CBBQS"] = "城市商业银行资金清算中心"; + map["HBRCU"] = "河北省农村信用社"; + + return map[bankId]; + } +} diff --git a/lib/hj_page/mine/mine_profit/widget/add_bank_dialog.dart b/lib/hj_page/mine/mine_profit/widget/add_bank_dialog.dart new file mode 100644 index 0000000..977b9ab --- /dev/null +++ b/lib/hj_page/mine/mine_profit/widget/add_bank_dialog.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/config/address.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/loading/loading_helper.dart'; +import 'package:hgdj/tools_base/net/http_manager.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/common_dialog.dart'; + +import '../../make_money/alipay_ccdcapi_model.dart'; + +class AddBankDialog extends StatefulWidget { + const AddBankDialog({super.key}); + + @override + State createState() => _AddBankDialogState(); +} + +class _AddBankDialogState extends State { + late final bankNameCtr = TextEditingController(); + late final bankNoCtr = TextEditingController(); + + @override + Widget build(BuildContext context) { + return CommonDialog( + canTapClose: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '添加银行卡', + style: TextStyle( + color: Colors.white, fontSize: 20, fontWeight: FontWeight.w500), + ), + 12.sizeBoxH, + Divider(height: .5, color: Colors.white.withValues(alpha: .1)), + 12.sizeBoxH, + Container( + height: 44, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .1), + borderRadius: BorderRadius.circular(6), + ), + alignment: Alignment.centerLeft, + padding: EdgeInsets.symmetric(horizontal: 16), + child: TextField( + maxLength: 20, + controller: bankNameCtr, + style: TextStyle(color: Colors.white), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'[a-zA-Z\u4E00-\u9F5a]')) + ], + decoration: InputDecoration( + border: InputBorder.none, + counterText: '', + contentPadding: EdgeInsets.zero, + isDense: true, + hintText: '请输入姓名', + hintStyle: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: .45), + fontWeight: FontWeight.w400, + ), + ), + ), + ), + 12.sizeBoxH, + Container( + height: 44, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .1), + borderRadius: BorderRadius.circular(6), + ), + alignment: Alignment.centerLeft, + padding: EdgeInsets.symmetric(horizontal: 16), + child: TextField( + maxLength: 20, + controller: bankNoCtr, + style: + TextStyle(color: Colors.white, fontWeight: FontWeight.w400), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')) + ], + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: InputBorder.none, + hintText: '请输入银行卡号', + counterText: '', + contentPadding: EdgeInsets.zero, + isDense: true, + hintStyle: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: .45), + fontWeight: FontWeight.w400, + ), + ), + ), + ), + 24.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () { + if (bankNoCtr.text.isEmpty) { + showToast('请输入银行卡号'); + return; + } + if (bankNameCtr.text.isEmpty) { + showToast('请输入持卡人姓名'); + return; + } + _bankNumberVerifyReq(); + }, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + "立即提交", + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + ), + ) + ], + ), + ); + } + + ///校验银行卡信息 + void _bankNumberVerifyReq() async { + String bankNum = bankNoCtr.text.trim(); + if (bankNum.isEmpty || bankNum.length < 13) { + showToast("银行卡号错误"); + return; + } + LoadingHelper.showLoading(); + httpManager.fetchResponseByGET(Address.aliCcdApi + bankNum).then((ret) { + LoadingHelper.dismissLoading(); + if (!ret.isSuccess) { + showToast("银行卡验证失败."); + return; + } + if (ret.isSuccess) { + final model = ApcApiModel.fromMap(Map.from(ret.data)); + if (model?.validated ?? false) { + // model.bank, model.cardType + debugLog("model.bank-->", "${model?.bank}"); + debugLog("model.cardType-->", "${model?.cardType}"); + // _commonWithdrawReq(true, bank: model?.bank); + _addBankCard(model!); + } else { + showToast("银行卡验证失败"); + } + } else { + showToast("银行卡验证失败"); + } + }); + } + + _addBankCard(ApcApiModel model) async { + final res = await MineService.addBankCard( + model.key, bankNameCtr.text, model.bank, model.cardType); + if (res) { + Get.back(result: true); + } + } +} diff --git a/lib/hj_page/mine/mine_setting/bind_phone_logic.dart b/lib/hj_page/mine/mine_setting/bind_phone_logic.dart new file mode 100644 index 0000000..8cdeea9 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/bind_phone_logic.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +import '../../../tools_base/event_bus/event_bus_util.dart'; +import '../../../tools_base/event_bus/events.dart'; +import 'bing_phone_page.dart'; + +class BindPhoneLogic extends GetxController { + late final phoneCtr = TextEditingController(); + late final codeCtr = TextEditingController(); + late final countDownNof = ValueNotifier(-1); + Timer? _timer; + final PhonePageType pageType; + + BindPhoneLogic(this.pageType); + + @override + onClose() { + super.onClose(); + _timer?.cancel(); + } + + onSendCode() async { + if (_timer != null) return; + if (phoneCtr.text.isEmpty || phoneCtr.text.length < 11) { + showToast('请输入正确的手机号~'); + return; + } + final res = await MineService.postCaptchaCode(phoneCtr.text, pageType.type); + if (res) { + _timer?.cancel(); + _timer = Timer.periodic(Duration(seconds: 1), (timer) { + countDownNof.value += 1; + if (countDownNof.value == 60) { + _timer?.cancel(); + _timer = null; + countDownNof.value = -1; + } + }); + } + } + + onBindPhone() async { + if (phoneCtr.text.isEmpty) { + showToast('请输入正确的手机号~'); + return; + } + if (codeCtr.text.isEmpty) { + showToast('请输入验证码~'); + return; + } + if (pageType == PhonePageType.bind) { + final res = await MineService.bindPhone(phoneCtr.text, codeCtr.text); + if (res) { + showToast('绑定成功'); + globalStore.updateUserInfo(); + Get.back(); + } + } else { + final result = + await globalStore.loginByMobile(phoneCtr.text, codeCtr.text); + if (result != null) { + showToast('登录成功'); + eventBus.emit(ReLoginEvent()); + } + } + } +} diff --git a/lib/hj_page/mine/mine_setting/bing_phone_page.dart b/lib/hj_page/mine/mine_setting/bing_phone_page.dart new file mode 100644 index 0000000..eca8423 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/bing_phone_page.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import 'bind_phone_logic.dart'; + +enum PhonePageType { + bind('绑定手机', 1, '立即绑定'), + find('找回账号', 2, '立即找回'); + + final int type; + final String title; + final String buttonTitle; + const PhonePageType(this.title, this.type, this.buttonTitle); +} + +class BindPhonePage extends StatelessWidget { + final PhonePageType pageType; + + const BindPhonePage({super.key, this.pageType = PhonePageType.bind}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: BindPhoneLogic(pageType), + builder: (controller) => Scaffold( + appBar: AppBar( + title: Text(controller.pageType.title), + ), + body: Padding( + padding: EdgeInsets.only(left: 16, right: 16, top: 47), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + height: 42, + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Text( + '手机号', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 16), + ), + 12.sizeBoxW, + Expanded( + child: TextField( + controller: controller.phoneCtr, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 14, + ), + maxLength: 11, + decoration: InputDecoration( + border: InputBorder.none, + hintText: '请输入手机号码', + isCollapsed: true, + contentPadding: EdgeInsets.zero, + counterText: '', + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 14)), + ), + ) + ], + ), + ), + 10.sizeBoxH, + Divider( + height: .5, + color: Colors.white.withValues(alpha: .05), + ), + 30.sizeBoxH, + Container( + width: double.infinity, + height: 42, + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Text( + '验证码', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 16), + ), + 12.sizeBoxW, + Expanded( + child: TextField( + controller: controller.codeCtr, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 14, + ), + maxLength: 6, + decoration: InputDecoration( + border: InputBorder.none, + hintText: '请输入手机验证码', + isCollapsed: true, + contentPadding: EdgeInsets.zero, + counterText: '', + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 14)), + ), + ), + 12.sizeBoxW, + GestureDetector( + onTap: () => controller.onSendCode(), + child: ValueListenableBuilder( + valueListenable: controller.countDownNof, + builder: + (BuildContext context, int value, Widget? child) { + final iscountdown = value > -1; + return Container( + padding: EdgeInsets.symmetric( + horizontal: 4, vertical: 4), + child: Text( + iscountdown ? '${60 - value}秒' : '获取验证码', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 12), + ), + ); + }, + ), + ) + ], + ), + ), + 10.sizeBoxH, + Divider( + height: .5, + color: Colors.white.withValues(alpha: .05), + ), + 30.sizeBoxH, + GestureDetector( + onTap: () => controller.onBindPhone(), + child: Container( + width: double.infinity, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed, + ), + child: Text( + controller.pageType.buttonTitle, + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500), + ), + ), + ) + ], + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_setting/invite_bind_logic.dart b/lib/hj_page/mine/mine_setting/invite_bind_logic.dart new file mode 100644 index 0000000..5c274ef --- /dev/null +++ b/lib/hj_page/mine/mine_setting/invite_bind_logic.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +/// 绑定邀请码逻辑 +class InviteBindLogic extends GetxController { + // 邀请码输入框 + final codeCtr = TextEditingController(); + + /// 立即绑定 + void bind() async { + if (codeCtr.text.isEmpty) { + showToast('请输入邀请码~'); + return; + } + final res = await MineService.exchangeInviteCode(codeCtr.text); + if (res) { + showToast('绑定成功'); + globalStore.updateUserInfo(); + Get.back(); + } else { + showToast('绑定失败'); + } + } + + @override + void onClose() { + codeCtr.dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/mine/mine_setting/invite_bind_page.dart b/lib/hj_page/mine/mine_setting/invite_bind_page.dart new file mode 100644 index 0000000..3869153 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/invite_bind_page.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import 'invite_bind_logic.dart'; + +/// 绑定邀请码页 +class InviteBindPage extends StatelessWidget { + const InviteBindPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: InviteBindLogic(), + builder: (controller) => Scaffold( + appBar: AppBar( + title: Text('绑定邀请码'), + ), + body: Padding( + padding: EdgeInsets.only(left: 28, right: 28, top: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Text( + '输入邀请码', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 30, + fontWeight: FontWeight.w500), + ), + 12.sizeBoxH, + Text( + '邀请码只能绑定一次 且不能修改', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.35), + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + 30.sizeBoxH, + // 邀请码输入 + SizedBox( + width: double.infinity, + height: 42, + child: Row( + children: [ + Text( + '邀请码', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 16), + ), + 24.sizeBoxW, + Expanded( + child: TextField( + controller: controller.codeCtr, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 14, + ), + maxLength: 11, + decoration: InputDecoration( + border: InputBorder.none, + hintText: '请输入邀请码(字母大写)', + isCollapsed: true, + contentPadding: EdgeInsets.zero, + counterText: '', + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.35), + fontSize: 14)), + ), + ) + ], + ), + ), + 10.sizeBoxH, + Divider( + height: .35, + color: Colors.white.withValues(alpha: .05), + ), + 18.sizeBoxH, + Text( + '邀请1人 获得1天会员', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.35), fontSize: 14), + ), + 30.sizeBoxH, + // 立即绑定按钮 + GestureDetector( + onTap: controller.bind, + child: Container( + width: double.infinity, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed, + ), + child: Text( + '立即绑定', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500), + ), + ), + ) + ], + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_setting/mine_code_logic.dart b/lib/hj_page/mine/mine_setting/mine_code_logic.dart new file mode 100644 index 0000000..219a6df --- /dev/null +++ b/lib/hj_page/mine/mine_setting/mine_code_logic.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/mine/exchange_record_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_alert_widget.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +class MineExchangeCodeLogic extends GetxController { + final int type; //0-填写邀请码 1-填写兑换码 + TextEditingController controller = TextEditingController(); + List groupList = []; + bool isLoadingHistoryData = true; + RefreshController? refreshController; //由 pullYsRefresh 的 onInit 注入,组件负责释放 + int currentPage = 1; + + MineExchangeCodeLogic({this.type = 0}); + + @override + onReady() { + super.onReady(); + if (type == 1) loadData(); + } + + loadData({int page = 1}) async { + try { + final res = await MineService.getExchangeRecord(page, 20); + if (res != null && res.data != null) { + if (page == 1) groupList.clear(); + currentPage = page; + groupList.addAll(res.data ?? []); + } + refreshController?.refreshCompleted(); + (res?.total ?? 0) > (res?.data?.length ?? 0) + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + } catch (e) { + refreshController?.refreshCompleted(); + refreshController?.loadComplete(); + debugLog(e); + } + update(); + isLoadingHistoryData = false; + } + + loadMoreData() => loadData(page: currentPage + 1); + + void onExChangeCode() async { + if (controller.text.isEmpty) { + showToast('请输入${type == 0 ? '邀请码' : '兑换码'}'); + return; + } + + if (type == 1) { + try { + LoadingAlertWidget.show(); + bool ret = await MineService.postExchangeCode(controller.text); + LoadingAlertWidget.cancel(); + if (ret == true) { + controller.text = ""; + globalStore.updateUserInfo(); + showToast("兑换成功"); + loadData(); + } + } catch (e) { + LoadingAlertWidget.cancel(); + debugLog(e); + } + } else if (type == 0) { + try { + LoadingAlertWidget.show(); + bool ret = await MineService.getProxyBind(controller.text); + LoadingAlertWidget.cancel(); + if (ret == true) { + globalStore.meInfo?.inviterCode = controller.text; + globalStore.updateUserInfo(); + showToast("绑定成功"); + Get.back(result: true); + } + } catch (e) { + LoadingAlertWidget.cancel(); + debugLog(e); + } + } + } +} diff --git a/lib/hj_page/mine/mine_setting/mine_code_page.dart b/lib/hj_page/mine/mine_setting/mine_code_page.dart new file mode 100644 index 0000000..f1f8c4e --- /dev/null +++ b/lib/hj_page/mine/mine_setting/mine_code_page.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/mine/exchange_record_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import 'mine_code_logic.dart'; + +//邀请码和兑换码 +class MineExchangeCodePage extends StatefulWidget { + final int type; //0-填写邀请码 1-填写兑换码 + const MineExchangeCodePage({super.key, this.type = 0}); + + @override + State createState() => _MineExchangeCodePageState(); +} + +class _MineExchangeCodePageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.type == 0 ? '邀请码' : '领取兑换')), + body: GetBuilder( + init: MineExchangeCodeLogic(type: widget.type), + builder: (_) => Padding( + padding: EdgeInsets.symmetric(vertical: 28, horizontal: 16), + child: Column( + children: [ + Expanded( + child: pullYsRefresh( + onInit: (controller) => _.refreshController = controller, + onRefresh: (controller) => _.loadData(), + onLoading: (controller) => _.loadMoreData(), + child: CustomScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverToBoxAdapter( + child: _buildContent(_), + ), + if (widget.type == 1) ...[ + _buildHistoryTable(_), + ] + ], + ), + ), + ), + InkWell( + enableFeedback: false, + onTap: () => _.onExChangeCode(), + child: Container( + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(3)), + color: AppColors.actionRed, + ), + child: Center( + child: Text( + "立即兑换", + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ), + ), + ), + ], + )), + ), + ); + } + + Widget _buildHistoryTable(MineExchangeCodeLogic logic) { + if (logic.isLoadingHistoryData) { + return SliverToBoxAdapter(child: LoadingCenterWidget()); + } else if (logic.groupList.isEmpty) { + return SliverToBoxAdapter( + child: CErrorWidget(errorMsg: "暂无兑换记录"), + ); + } else { + return SliverList.separated( + itemBuilder: (context, index) { + return _buildListItem(logic.groupList[index]); + }, + itemCount: logic.groupList.length, + separatorBuilder: (context, index) { + return Divider( + height: 1, + color: Colors.black87.withValues(alpha: 0.1), + ); + }, + ); + } + } + + _buildContent(MineExchangeCodeLogic _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "输入兑换码", + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontWeight: FontWeight.w500, + fontSize: 30, + ), + ), + 12.sizeBoxH, + Text( + "每个兑换码只能输入一次", + style: TextStyle( + color: Color(0xff525252), + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + 56.sizeBoxH, + Container( + height: 42, + child: Row( + children: [ + Text( + "兑换码", + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + 16.sizeBoxW, + Expanded( + child: TextField( + keyboardType: TextInputType.text, + autofocus: true, + autocorrect: true, + cursorColor: Colors.white, + textAlign: TextAlign.left, + controller: _.controller, + style: TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + hintText: '请输入${widget.type == 0 ? '邀请码' : '兑换码'}(字母大写)', + hintStyle: TextStyle(color: Color(0xff434c55)), + border: InputBorder.none, + ), + ), + ) + ], + )), + 12.sizeBoxH, + 1.line, + 18.sizeBoxH, + Text( + "官方社群领取更多福利", + style: TextStyle( + color: Color(0xff525252), + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + 36.sizeBoxH, + Text( + "兑换记录", + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ), + 12.sizeBoxH, + Row( + children: [ + Expanded(child: _buildSectionItem('兑换码')), + 5.sizeBoxW, + Expanded(child: _buildSectionItem('兑换类型')), + 5.sizeBoxW, + Expanded(child: _buildSectionItem('兑换时间')), + ], + ), + ], + ); + } + + _buildSectionItem(String title) { + return Container( + alignment: Alignment.center, + height: 42, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + ), + child: Text( + title, + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontWeight: FontWeight.w400, + fontSize: 14, + ), + ), + ); + } + + Widget _buildListItem(ExchangeRecordModel item) { + return Container( + height: 44, + child: Row( + children: [ + Flexible( + child: Container( + height: 44, + alignment: Alignment.center, + child: Text( + item.code ?? '', + style: TextStyle( + color: Colors.white.withValues(alpha: .55), fontSize: 14), + ), + ), + ), + Flexible( + child: Container( + height: 44, + alignment: Alignment.center, + child: Text( + item.desc ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: .55), fontSize: 14), + ), + ), + ), + Flexible( + child: Container( + height: 44, + alignment: Alignment.center, + child: Text( + '${item.createdAt.utcToYMD(gap: '.')}', + style: TextStyle( + color: Colors.white.withValues(alpha: .55), fontSize: 14), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_setting/mine_find_account_page.dart b/lib/hj_page/mine/mine_setting/mine_find_account_page.dart new file mode 100644 index 0000000..febd72e --- /dev/null +++ b/lib/hj_page/mine/mine_setting/mine_find_account_page.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../hj_utils/permission_util.dart'; +import '../../../routers/jump_router.dart'; +import 'bing_phone_page.dart'; +import 'mine_scan_login_page.dart'; + +class MineFindAccountPage extends StatelessWidget { + late final dataSource = [ + { + 'title': '手机号找回', + 'ontap': () => + Get.to(() => const BindPhonePage(pageType: PhonePageType.find)), + }, + { + 'title': '凭证找回', + 'ontap': () async { + if (await PermissionUtil.checkCameraPermission()) { + Get.to(() => const MineScanLoginPage()); + } + } + }, + { + 'title': '联系客服', + 'ontap': () => pushToCustomService(), + } + ]; + MineFindAccountPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(elevation: 0, title: Text('找回账号')), + body: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(8), + ), + child: ListView.separated( + physics: NeverScrollableScrollPhysics(), + itemCount: dataSource.length, + shrinkWrap: true, + separatorBuilder: (_, __) => Divider( + height: 0.5, + color: Colors.white.withValues(alpha: .1), + ), + itemBuilder: (BuildContext context, int index) { + final data = dataSource[index]; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: data['ontap'] as Function()?, + child: Column( + children: [ + 16.sizeBoxH, + Row( + children: [ + Text( + (data['title'] ?? '').toString(), + style: TextStyle( + fontSize: 14, + color: Colors.white.withValues(alpha: 0.9)), + ), + Spacer(), + Icon( + Icons.arrow_forward_ios_rounded, + size: 14, + color: Color(0xFFDCDCDC), + ) + ], + ), + 16.sizeBoxH, + ], + ), + ); + }, + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_setting/mine_password_logic.dart b/lib/hj_page/mine/mine_setting/mine_password_logic.dart new file mode 100644 index 0000000..93fb8a9 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/mine_password_logic.dart @@ -0,0 +1,39 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/splash/splash_page.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +/// 手势密码用途:设置 / 关闭 / 进 app 解锁校验 +enum MinePasswordType { setting, close, check } + +class MinePasswordLogic extends GetxController { + //本页是中间件 redirect 的目标(RouteSettings 只能带 arguments),没法改构造传参; + //用 is 判断而不是隐式强转:路由栈里读到别的页的 arguments 时只降级不抛 TypeError + final MinePasswordType type = _typeFromArgs(); + + static MinePasswordType _typeFromArgs() { + final args = Get.arguments; + return args is MinePasswordType ? args : MinePasswordType.setting; + } + + bool get isCheck => type == MinePasswordType.check; + + String get title => isCheck ? '请输入解锁密码' : '请绘制锁屏图形'; + + /// 画完手势:设置/关闭成功即退出本页,解锁通过则回启动页重走流程 + Future onComplete(List result) async { + if (result.length < 4) { + showToast('请至少链接4个点'); + return; + } + switch (type) { + case MinePasswordType.setting: + if (await globalStore.setLockPassword(result) == true) Get.back(); + case MinePasswordType.close: + if (await globalStore.closeLockPassword(result) == true) Get.back(); + case MinePasswordType.check: + if (await globalStore.checkLockPassword(result) == true) + Get.toNamed(SplashPage.routeName); + } + } +} diff --git a/lib/hj_page/mine/mine_setting/mine_password_page.dart b/lib/hj_page/mine/mine_setting/mine_password_page.dart new file mode 100644 index 0000000..1df87a5 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/mine_password_page.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../mine_gesture_pwd/widget/gesture_password_widget.dart'; +import 'mine_password_logic.dart'; + +/// 手势密码页:设置 / 关闭 / 解锁校验共用,用途由路由 arguments 传 [MinePasswordType] +class MinePasswordPage extends StatelessWidget { + static const routeName = '/MinePasswordPage'; + const MinePasswordPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MinePasswordLogic(), + //必须 false:type 来自路由 arguments,全局注册会让「关闭」复用上一次「设置/解锁」残留的 logic + global: false, + builder: (logic) => Scaffold( + //解锁校验时还进不了 app,不给返回入口 + appBar: logic.isCheck ? null : AppBar(title: const Text('手势密码')), + //必须给满宽约束:Column 横向是按最宽的子节点收缩的(这里 250),而 Scaffold 的 body + //是贴 (0,0) 摆放的,不撑满就整块贴左边;撑满后 Column 默认的居中对齐才真正生效 + body: SizedBox( + width: double.infinity, + child: Column( + children: [ + //没有 AppBar 顶着,用锁图标占位 + if (logic.isCheck) ...[ + 65.sizeBoxH, + Image.asset('mine_lock_password.png'.mineImgPath, width: 29), + ], + 55.sizeBoxH, + Text( + logic.title, + style: TextStyle( + color: Colors.black.withValues(alpha: 0.9), fontSize: 18), + ), + 75.sizeBoxH, + GesturePasswordWidget( + lineColor: const Color(0xFFF52C56), + errorLineColor: const Color(0xffDD001B), + singleLineCount: 3, + identifySize: 60.0, + size: 250, + minLength: 4, + errorItem: Image.asset('error.webp'.mineImgPath, + color: const Color(0xFFF52C56)), + selectedItem: Image.asset('select.png'.mineImgPath, + color: const Color(0xFFF52C56)), + normalItem: Image.asset('normal.png'.mineImgPath), + onComplete: logic.onComplete, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_setting/mine_scan_login_logic.dart b/lib/hj_page/mine/mine_setting/mine_scan_login_logic.dart new file mode 100644 index 0000000..0a3b0d0 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/mine_scan_login_logic.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:image_pickers/image_pickers.dart'; +import 'package:hgdj/hj_utils/image_util.dart'; +import 'package:hgdj/tools_base/event_bus/event_bus_util.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_helper.dart'; +import 'package:hgdj/tools_base/net/net_manager.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:qr_code_scanner/qr_code_scanner.dart'; + +import '../identity/mine_identity_page.dart'; + +class MineScanLginLogic extends GetxController { + QRViewController? scanController; + + @override + void onClose() { + scanController?.dispose(); + super.onClose(); + } + + onQRViewCreated(QRViewController controller) { + scanController = controller; + + scanController?.scannedDataStream.listen((scanData) { + print('扫码结果:${scanData.code}'); + loginByQrValue(scanData.code ?? ''); + }); + } + + /// 开启本地相册 + openNativePhoto() async { + // image_picker 走系统相册 intent,选图不需要存储权限,直接调起 + final images = await ImagePickers.pickerPaths( + uiConfig: UIConfig(uiThemeColor: Colors.white), + selectCount: 1, + showCamera: false, + cropConfig: CropConfig(enableCrop: false), + ); + if (images.isNotEmpty) { + final qrValue = await ImageUtil.decodeQr(images[0].path ?? ''); + if (qrValue == null || qrValue.isEmpty) { + showToast('二维码错误~~'); + return; + } + loginByQrValue(qrValue); + } + } + + /// 是否正在处理一次扫码登录。扫码流会按摄像头帧连续 emit,pauseCamera 异步拦不住已缓冲的帧, + /// 不加锁会并发触发 N 次 loginByQr → token 被反复清空/轮换,服务端单会话把旧 token 全废, + /// 满屏 5009/5005「用户信息已经过期」,最终 token 落空、换号失败。 + bool _isHandling = false; + + /// 开始二维码登录 + loginByQrValue(String qrValue) async { + if (_isHandling) return; // 一次扫码只处理一次,挡掉连续帧/重复触发 + if (qrValue.isEmpty) { + showToast("二维码为空"); + return; + } + _isHandling = true; + scanController?.pauseCamera(); + LoadingHelper.showLoading(); + var userInfo = await globalStore.loginByQr(qrValue); + LoadingHelper.dismissLoading(); + // 必须拿到 token 才算成功:只有 uid 没 token 会留下空 token,下一个请求立刻被判过期 + if (userInfo?.uid == null || (userInfo?.token ?? '').isEmpty) { + showToast("切换账号失败"); + scanController?.resumeCamera(); + _isHandling = false; // 失败放开,允许重试 + return; + } + // 刷新ua + netManager.refreshUserAgent(); + showToast('登录成功'); + eventBus.emit(ReLoginEvent()); + } + + /// 跳转凭证 + jumpToCertificate() => Get.to(MineAccountIdentityPage()); +} diff --git a/lib/hj_page/mine/mine_setting/mine_scan_login_page.dart b/lib/hj_page/mine/mine_setting/mine_scan_login_page.dart new file mode 100644 index 0000000..d8d0e94 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/mine_scan_login_page.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:qr_code_scanner/qr_code_scanner.dart'; + +import 'mine_scan_login_logic.dart'; + +class MineScanLoginPage extends StatefulWidget { + const MineScanLoginPage({super.key}); + + @override + State createState() => _MineScanLoginPageState(); +} + +class _MineScanLoginPageState extends State { + final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MineScanLginLogic(), + builder: (controller) => Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + elevation: 0, + iconTheme: IconThemeData(color: Colors.white), + backgroundColor: Colors.transparent, + title: Text( + '扫码登录', + style: TextStyle( + color: Colors.white, fontSize: 18, fontWeight: FontWeight.w500), + ), + ), + body: Stack( + children: [ + QRView( + key: qrKey, + onQRViewCreated: controller.onQRViewCreated, + overlay: QrScannerOverlayShape( + overlayColor: Colors.black, + borderColor: Colors.white, + borderRadius: 0, + borderLength: 20, + borderWidth: 5, + cutOutSize: 235), + ), + Positioned( + bottom: 100, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: GestureDetector( + onTap: controller.openNativePhoto, + child: Column( + children: [ + Image.asset('scan_photo.png'.mineImgPath, width: 48), + 12.sizeBoxH, + Text( + '相册', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500), + ) + ], + ), + ), + ), + Expanded( + child: GestureDetector( + onTap: controller.jumpToCertificate, + child: Column( + children: [ + Image.asset('mine_id.png'.mineImgPath, width: 48), + 12.sizeBoxH, + Text( + '我的凭证', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500), + ) + ], + ), + ), + ), + ], + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_setting/mine_setting_page.dart b/lib/hj_page/mine/mine_setting/mine_setting_page.dart new file mode 100644 index 0000000..09b4700 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/mine_setting_page.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/hj_utils/version_util.dart'; +import 'package:hgdj/hj_utils/video_cache_manager.dart'; +import 'package:hgdj/tools_base/cache/cache_util.dart'; +import 'package:hgdj/tools_base/cache/image_cache_manager.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/image/image_data_handle/image_cache_disk.dart'; +import 'package:hgdj/tools_base/video_download/video_download_manager.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; + +import '../../../alert/splash/update_dialog.dart'; +import '../identity/mine_identity_page.dart'; +import '../more_question/mine_qa_page.dart'; +import 'bing_phone_page.dart'; +import 'invite_bind_page.dart'; +import 'mine_find_account_page.dart'; +import 'mine_setting_profile_page.dart'; +import 'setting_avatar_page.dart'; + +class MineSettingPage extends StatefulWidget { + const MineSettingPage({super.key}); + + @override + State createState() => _MineSettingPageState(); +} + +class _MineSettingPageState extends State { + String cacheSize = ''; // 缓存大小 + + @override + void initState() { + super.initState(); + getCacheSize(); + } + + Future getCacheSize() async { + try { + cacheSize = await loadCache(); + setState(() {}); + } catch (e) { + debugPrint(e.toString()); + } + } + + @override + Widget build(BuildContext context) { + final meInfo = context.watch().meInfo; + final mobile = meInfo?.mobile ?? ''; + final inviterCode = meInfo?.inviterCode ?? ''; + return Scaffold( + appBar: AppBar(title: Text('设置中心')), + body: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(8), + ), + margin: EdgeInsets.only( + left: 10, + top: 12, + right: 10, + ), + child: ListView( + children: [ + _buildItem( + title: '头像', + avatarUrl: meInfo?.portrait ?? '', + onTap: () async { + if (!globalStore.isVIP) { + if (await CommonAlert.show( + content: '您还不是VIP无法修改头像', + subContent: '开通会员 即可解锁继续', + showCancel: false, + confirmText: '开通会员', + )) { + pushToWalletPage(); + } + return; + } + Get.to(() => const SettingAvatarPage()); + }), + _buildLine(), + _buildItem( + title: '昵称', + subTitle: meInfo?.name, + onTap: () async { + if (!globalStore.isVIP) { + if (await CommonAlert.show( + content: '您还不是VIP无法修改昵称!', + subContent: '开通会员 即可解锁继续', + showCancel: false, + confirmText: '开通会员', + )) { + pushToWalletPage(); + } + return; + } + Get.to(() => + const SettingProfilePage(SettingProfileType.nickName)); + }), + _buildLine(), + _buildItem( + title: '${Config.appName}ID', + subTitle: meInfo?.uid.toString(), + showCopy: true, + onTap: () {}), + _buildLine(), + _buildItem( + title: '手机号码', + subTitle: mobile.isEmpty ? '立即绑定' : mobile, + onTap: () { + Get.to(() => const BindPhonePage()); + }), + _buildLine(), + _buildItem( + title: '账号找回', + onTap: () { + Get.to(() => MineFindAccountPage()); + }), + _buildLine(), + _buildItem( + title: '邀请码', + subTitle: inviterCode.isEmpty ? '未设置' : inviterCode, + onTap: () { + if (inviterCode.isEmpty) { + Get.to(() => const InviteBindPage()); + } else { + showToast('您已绑定过邀请码'); + } + }), + _buildLine(), + _buildItem( + title: '账号凭证', + onTap: () { + Get.to(() => MineAccountIdentityPage()); + }), + _buildLine(), + _buildItem( + title: '常见问题', + onTap: () { + Get.to(() => MineQAPage()); + }), + _buildLine(), + _buildItem( + title: '清除缓存', + subTitle: cacheSize, + onTap: () async { + await VideoDownloadManager.instance.emptyCache(); + await ImageCacheDisk.emptyCache(); + await VideoCacheManager().emptyCache(); + await ImageCacheManager().emptyCache(); + cacheSize = "0.0KB"; + if (mounted) setState(() {}); + showToast('清理缓存成功'); + }), + _buildLine(), + _buildItem( + title: '检查更新', + subTitle: 'V${Config.innerVersion}', + onTap: () { + //比对启动页拉到的版本配置,确实有新版才弹更新框 + final target = checkUpdate(); + target == null + ? showToast("当前已是最新版!") + : UpdateDialog.show(target); + }), + _buildLine(), + ], + ), + ), + ); + } + + Widget _buildLine() { + return Padding(padding: EdgeInsets.only(left: 16), child: .5.line); + } + + Widget _buildItem({ + String title = '', + String? subTitle, + bool showCopy = false, + String? avatarUrl, + VoidCallback? onTap, + }) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onTap, + child: Container( + height: 56, + padding: EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Text( + title, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), fontSize: 14), + ), + Spacer(), + if (avatarUrl != null) + NetworkImageLoader( + imageUrl: avatarUrl, + width: 24, + height: 24, + borderRadius: 12, + ), + if (subTitle != null) + Text( + subTitle, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.45), fontSize: 12), + ), + if (showCopy) ...[ + 4.sizeBoxW, + GestureDetector( + onTap: () { + Clipboard.setData( + ClipboardData(text: '${globalStore.meInfo?.uid}')); + showToast('复制成功'); + }, + child: Image.asset( + 'mine_copy.png'.mineImgPath, + width: 24, + ), + ), + ], + if (!showCopy) ...[ + 10.sizeBoxW, + Icon( + Icons.arrow_forward_ios_rounded, + size: 14, + color: Color(0xFF70708C), + ), + ] + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_setting/mine_setting_profile_logic.dart b/lib/hj_page/mine/mine_setting/mine_setting_profile_logic.dart new file mode 100644 index 0000000..4a907b3 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/mine_setting_profile_logic.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_helper.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import 'mine_setting_profile_page.dart'; +import 'setting_profile_widget.dart'; + +abstract class MineSettingProfilePage extends GetxController { + String get title => ''; + + late final nickNameTfCtr = TextEditingController(); + late final sloganTfCtr = TextEditingController(); + + onSaveProfile(); + + instanceChildItem(int index); + + @override + void onClose() { + nickNameTfCtr.dispose(); + sloganTfCtr.dispose(); + super.onClose(); + } +} + +class SettingNickNameController extends MineSettingProfilePage { + @override + String get title => '修改昵称'; + + @override + onSaveProfile() async { + if (nickNameTfCtr.text.isEmpty) { + showToast('昵称不能为空~'); + return; + } + LoadingHelper.showLoading(); + final res = await MineService.updateUserInfo({'name': nickNameTfCtr.text}); + LoadingHelper.dismissLoading(); + if (res) { + globalStore.updateUserInfo(); + Get.back(); + } + } + + @override + instanceChildItem(int index) { + return SettingNickName(); + } +} + +class SettingSloganController extends MineSettingProfilePage { + @override + String get title => '个性签名'; + + @override + onSaveProfile() async { + if (sloganTfCtr.text.isEmpty) { + showToast('请输入个性签名~'); + return; + } + LoadingHelper.showLoading(); + final res = await MineService.updateUserInfo({'summary': sloganTfCtr.text}); + LoadingHelper.dismissLoading(); + if (res) { + globalStore.updateUserInfo(); + Get.back(); + } + } + + @override + instanceChildItem(int index) { + return SettingSlogan(); + } +} + +MineSettingProfilePage instanceSettingProfileController( + SettingProfileType type) { + switch (type) { + case SettingProfileType.nickName: + return SettingNickNameController(); + case SettingProfileType.slogan: + return SettingSloganController(); + default: + throw ''; + } +} diff --git a/lib/hj_page/mine/mine_setting/mine_setting_profile_page.dart b/lib/hj_page/mine/mine_setting/mine_setting_profile_page.dart new file mode 100644 index 0000000..150d306 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/mine_setting_profile_page.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import 'mine_setting_profile_logic.dart'; + +enum SettingProfileType { + nickName, + slogan, +} + +class SettingProfilePage extends StatelessWidget { + final SettingProfileType type; + const SettingProfilePage(this.type, {super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: instanceSettingProfileController(type), + builder: (controller) => Scaffold( + appBar: AppBar( + title: Text(controller.title), + actions: [ + GestureDetector( + onTap: () => controller.onSaveProfile(), + child: Text( + '保存', + style: TextStyle(color: Color(0xff999999), fontSize: 14), + ), + ), + 16.sizeBoxW + ], + ), + body: controller.instanceChildItem(0), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_setting/setting_avatar_logic.dart b/lib/hj_page/mine/mine_setting/setting_avatar_logic.dart new file mode 100644 index 0000000..4a59efd --- /dev/null +++ b/lib/hj_page/mine/mine_setting/setting_avatar_logic.dart @@ -0,0 +1,49 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +class SettingAvatarLogic extends ListBaseLogic { + int selectIndex = -1; + late final int rawIndex; + + @override + void onReady() { + super.onReady(); + loadData(); + } + + void loadData() => fetchData(isRefresh: true, fetch: _fetch); + + //头像列表无分页,单次拉取,hasNext 固定 false + Future<(List?, bool)> _fetch(int page) async { + final res = await MineService.getPortrait(); + selectIndex = res.indexOf(globalStore.meInfo?.portrait ?? ''); + rawIndex = selectIndex; + return (res, false); + } + + selectAavatar(int index) { + selectIndex = index; + update(); + } + + confirmChangeAvatar() async { + if (globalStore.meInfo?.urrPortraitStatus == 1) { + showToast('头像审核中~'); + return; + } + if (selectIndex == rawIndex || selectIndex == -1) { + showToast('你都没选择,保存什么~'); + return; + } + final res = await MineService.updateUserInfo( + {'portrait': dataList![selectIndex], 'isDefaultSource': true}); + if (res) { + globalStore.updateUserInfo(); + showToast('更新成功'); + Get.back(result: true); + } + } +} diff --git a/lib/hj_page/mine/mine_setting/setting_avatar_page.dart b/lib/hj_page/mine/mine_setting/setting_avatar_page.dart new file mode 100644 index 0000000..7f2ecaf --- /dev/null +++ b/lib/hj_page/mine/mine_setting/setting_avatar_page.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import 'setting_avatar_logic.dart'; + +class SettingAvatarPage extends StatelessWidget { + const SettingAvatarPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: SettingAvatarLogic(), + builder: (controller) => Scaffold( + appBar: AppBar( + title: Text('选择头像'), + actions: [ + InkWell( + enableFeedback: false, + onTap: controller.confirmChangeAvatar, + child: Text( + '保存', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.55), fontSize: 12), + ), + ), + 16.sizeBoxW, + ], + ), + body: () { + if (controller.isLoading) return LoadingCenterWidget(); + if (controller.isEmptyData) return CErrorWidget(); + final list = controller.dataList!; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + 30.verticalSpace, + NetworkImageLoader( + imageUrl: context.watch().meInfo?.portrait ?? '', + width: 90, + height: 90, + borderRadius: 45, + ), + 10.verticalSpace, + Text( + '当前头像', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.55), fontSize: 12), + ), + 20.verticalSpace, + Expanded( + child: GridView.builder( + padding: EdgeInsets.only(left: 38, right: 38, top: 0), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 20, + crossAxisSpacing: 20, + childAspectRatio: 1, + ), + itemCount: list.length, + itemBuilder: (BuildContext context, int index) { + final select = controller.selectIndex == index; + return GestureDetector( + onTap: () => controller.selectAavatar(index), + child: Stack( + children: [ + NetworkImageLoader( + imageUrl: list[index], + width: double.infinity, + height: double.infinity, + borderRadius: 100, + ), + if (select) + Align( + alignment: Alignment.bottomRight, + child: Image.asset( + 'red_checked.png'.mineImgPath, + width: 22, + height: 22, + ), + ) + ], + ), + ); + }, + ), + ), + 12.sizeBoxH, + ], + ); + }(), + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_setting/setting_profile_widget.dart b/lib/hj_page/mine/mine_setting/setting_profile_widget.dart new file mode 100644 index 0000000..a5f4b50 --- /dev/null +++ b/lib/hj_page/mine/mine_setting/setting_profile_widget.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +import 'mine_setting_profile_logic.dart'; + +class SettingNickName extends StatefulWidget { + const SettingNickName({super.key}); + + @override + State createState() => _SettingNickNameState(); +} + +class _SettingNickNameState extends State { + late final controller = Get.find(); + @override + void initState() { + super.initState(); + controller.nickNameTfCtr.text = globalStore.meInfo?.name ?? ''; + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 60.sizeBoxH, + Row( + children: [ + Expanded( + child: TextField( + controller: controller.nickNameTfCtr, + maxLength: 20, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), fontSize: 18), + decoration: InputDecoration( + border: InputBorder.none, + hintText: '输入新的昵称', + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.35), + fontSize: 18), + counterText: '', + isCollapsed: true, + contentPadding: EdgeInsets.zero), + ), + ), + InkWell( + enableFeedback: false, + onTap: () => controller.nickNameTfCtr.clear(), + child: Container( + padding: EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(90)), + child: Image.asset( + 'close_button.png'.commonImgPath, + width: 24, + )), + ) + ], + ), + 12.sizeBoxH, + 0.5.line, + 12.sizeBoxH, + Text( + '注意*诱导性昵称会被投诉封号', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.35), fontSize: 12), + ) + ], + ), + ); + } +} + +class SettingSlogan extends StatefulWidget { + const SettingSlogan({super.key}); + + @override + State createState() => _SettingSloganState(); +} + +class _SettingSloganState extends State { + late final controller = Get.find(); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.only(left: 16, top: 12, right: 16), + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: .04), + borderRadius: BorderRadius.circular(8)), + constraints: BoxConstraints( + minHeight: 111, + ), + child: Stack( + children: [ + TextField( + maxLines: 10, + style: const TextStyle(color: Color(0xff333333), fontSize: 12), + maxLength: 150, + controller: controller.sloganTfCtr, + decoration: InputDecoration( + border: InputBorder.none, + hintText: '有趣的介绍能让你的逼格提高N个档次!...', + hintStyle: TextStyle(color: Color(0xff999999), fontSize: 12), + counterText: '', + contentPadding: EdgeInsets.zero, + isDense: true), + ), + Positioned( + bottom: 0, + right: 0, + // 字数计数:只监听不接管所有权(ChangeNotifierProvider(create:) 会把 ctr 一起 dispose, + // 而它归 MineSettingProfilePage.onClose 释放 → 二次释放) + child: ValueListenableBuilder( + valueListenable: controller.sloganTfCtr, + builder: (_, value, __) => Text( + '${value.text.length}/150', + style: TextStyle( + color: Color(0xff666666), + fontSize: 12, + ), + ), + ), + ) + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_share/mine_share_page.dart b/lib/hj_page/mine/mine_share/mine_share_page.dart new file mode 100644 index 0000000..747f097 --- /dev/null +++ b/lib/hj_page/mine/mine_share/mine_share_page.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/address.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../../../hj_utils/image_util.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import 'mine_share_record_page.dart'; + +//分享邀请 + +class MineSharePage extends StatelessWidget { + // 该页只通过 Get.to 作为路由根打开,实例稳定不会被父级 rebuild, + // boundaryKey 字段只创建一次,RepaintBoundary 截图正常 + final GlobalKey boundaryKey = GlobalKey(); + + MineSharePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("邀请分享"), + actions: [ + InkWell( + enableFeedback: false, + onTap: () { + Get.to(() => MineShareRecordPage()); + }, + child: Text( + "记录", + style: TextStyle( + color: Colors.white.withValues(alpha: .45), + fontSize: 12, + ), + ), + ), + 16.sizeBoxW, + ], + ), + body: Container( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + // 用 FittedBox 完整渲染卡片,不放进 SingleChildScrollView: + // RepaintBoundary 在可滚动容器内时,toImage 受 Viewport 裁剪影响会截图残缺/失败 + child: Center( + child: FittedBox( + fit: BoxFit.contain, + child: RepaintBoundary( + key: boundaryKey, + child: Container( + margin: EdgeInsets.only(top: 30), + padding: EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: 300, + height: 550, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(9), + child: Image.asset('share_bg.webp'.mineImgPath), + ), + // 整张卡片统一响应 meInfo 变化:头像/邀请码/二维码同源刷新 + Consumer( + builder: (_, provider, __) { + final me = provider.meInfo; + return Column( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + 30.sizeBoxH, + Container( + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(45), + border: Border.all( + color: Color(0x4DF68804), + width: 3), + ), + child: NetworkImageLoader( + imageUrl: me?.portrait ?? '', + width: 82, + height: 82, + borderRadius: 45), + ), + 10.sizeBoxH, + Text( + '我的邀请码', + style: TextStyle( + color: Colors.white + .withValues(alpha: .9), + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + 10.sizeBoxH, + Text( + me?.promotionCode ?? "", + style: TextStyle( + color: Colors.white + .withValues(alpha: .55), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + 30.sizeBoxH, + Text("每邀请3人,送3天VIP", + style: TextStyle( + fontSize: 14, + color: Color(0xFFF68804))), + 10.sizeBoxH, + Row( + children: [ + Spacer(), + Center( + child: Container( + padding: EdgeInsets.all(11.w), + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage( + "code_bg.webp" + .mineImgPath), + fit: BoxFit.fill, + ), + ), + child: Container( + padding: EdgeInsets.all(6.w), + color: Colors.white, + child: QrImageView( + data: me?.promoteURL ?? "", + version: QrVersions.auto, + size: 100, + backgroundColor: Colors.white, + ), + ), + ), + ), + Spacer(), + ], + ), + 4.sizeBoxH, + Text( + '提示*苹果手机请用相机扫码/安卓手机\n推荐UC浏览器扫码', + style: TextStyle( + color: Colors.white + .withValues(alpha: .55), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + textAlign: TextAlign.center, + ), + 6.sizeBoxH, + Container( + height: 30, + padding: EdgeInsets.symmetric( + horizontal: 18, vertical: 3), + child: Text( + '${Config.appName} 官网地址 ${Address.groundUrl ?? ""}', + style: TextStyle( + color: Colors.white + .withValues(alpha: .55), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ), + ], + ); + }, + ) + ], + ), + ), + ), + ), + ), + ), + ), + Container( + margin: EdgeInsets.symmetric(horizontal: 20), + padding: EdgeInsets.symmetric(vertical: 12), + child: Column( + children: [ + GestureDetector( + onTap: () async { + final ok = + await ImageUtil.saveWidgetToAlbum(boundaryKey); + showToast(ok ? "保存成功" : "保存失败,请重试"); + }, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed, + ), + child: Text( + "保存图片", + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + 16.sizeBoxH, + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData( + text: globalStore.meInfo?.promoteURL ?? "")); + showToast('复制成功'); + }, + child: Container( + width: 112, + height: 44, + alignment: Alignment.center, + child: Text( + "复制链接", + style: TextStyle( + fontSize: 12, + color: Color(0x8CFFFFFF), + decoration: TextDecoration.underline, // 添加下划线 + decorationColor: Color(0xFF11887C), // 下划线颜色 + decorationThickness: 1.0, // 下划线粗细 + decorationStyle: TextDecorationStyle.solid, // 下划线样式 + ), + ), + ), + ), + ], + ), + ), + ], + )), + ); + } +} diff --git a/lib/hj_page/mine/mine_share/mine_share_record_logic.dart b/lib/hj_page/mine/mine_share/mine_share_record_logic.dart new file mode 100644 index 0000000..6ef9a57 --- /dev/null +++ b/lib/hj_page/mine/mine_share/mine_share_record_logic.dart @@ -0,0 +1,31 @@ +import 'package:hgdj/hj_model/user/user_income_info_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +import '../../../hj_model/mine/promotion_record.dart'; + +class MineShareRecordLogic extends ListBaseLogic { + UserIncomeModel? model; // 邀请收益头部信息(独立接口,与列表分页无关) + + @override + void onReady() { + super.onReady(); + loadInfo(); + loadData(); + } + + // 头部邀请收益信息 + void loadInfo() async { + model = await MineService.fetchIncomeInfo(); + update(); + } + + // 邀请记录列表:下拉刷新 / 上拉加载更多(分页、防重入、异常兜底交给基类) + void loadData({bool isRefresh = true}) => + fetchData(isRefresh: isRefresh, fetch: _fetch); + + Future<(List?, bool)> _fetch(int page) async { + final resp = await MineService.getBindRecord(10, page); + return (resp?.list, resp?.hasNext == true); // hasNext==true 才有更多(保持原语义) + } +} diff --git a/lib/hj_page/mine/mine_share/mine_share_record_page.dart b/lib/hj_page/mine/mine_share/mine_share_record_page.dart new file mode 100644 index 0000000..8aff200 --- /dev/null +++ b/lib/hj_page/mine/mine_share/mine_share_record_page.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/mine/promotion_record.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../hj_utils/widget_util.dart'; +import 'mine_share_record_logic.dart'; + +class MineShareRecordPage extends StatelessWidget { + const MineShareRecordPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MineShareRecordLogic(), + builder: (logic) { + return Scaffold( + appBar: AppBar(title: Text("邀请记录")), + body: Column( + children: [ + 12.sizeBoxH, + Text('已邀请:${logic.model?.totalInviteUserCount ?? 0}人', + style: textStyle(12, Color(0xff525252), FontWeight.w400)), + Expanded( + child: pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onLoading: (_) => logic.loadData(isRefresh: false), + onRefresh: (_) => logic.loadData(), + child: _buildBody(logic), + ), + ), + ], + ), + ); + }, + ); + } + + // loading / 空数据 / 列表 三态 + Widget _buildBody(MineShareRecordLogic logic) { + if (logic.isLoading) return LoadingCenterWidget(); + if (logic.isEmptyData) return CErrorWidget(); + final list = logic.dataList ?? []; + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shrinkWrap: true, + itemCount: list.length, + separatorBuilder: (_, __) => + Divider(height: 0.5, color: Colors.white.withValues(alpha: .1)), + itemBuilder: (_, index) => _buildItemView(list[index]), + ); + } + + // 单条邀请记录:头像 + 昵称 + 注册时间 + Widget _buildItemView(Promotion item) { + return Container( + height: 30, + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + NetworkImageLoader( + imageUrl: item.portrait ?? '', + width: 30, + height: 30, + borderRadius: 30, + ), + 11.sizeBoxW, + Text( + item.name ?? '', + style: TextStyle( + color: Color(0xffffffff), + fontWeight: FontWeight.w500, + fontSize: 14.0), + ), + Spacer(), + Text( + '注册时间: ${item.createAt.utcToYMD(gap: '/')}', + style: TextStyle( + color: Color(0xff989898), + fontWeight: FontWeight.w400, + fontSize: 12.0), + ) + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_video_cache/video_cache_cell.dart b/lib/hj_page/mine/mine_video_cache/video_cache_cell.dart new file mode 100644 index 0000000..00faa45 --- /dev/null +++ b/lib/hj_page/mine/mine_video_cache/video_cache_cell.dart @@ -0,0 +1,382 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/api_service/drama_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../hj_model/video_model.dart'; +import '../../../tools_base/toast.dart'; +import '../../../tools_base/video_download/video_save_util.dart'; +import '../../../tools_base/video_download/video_download_manager.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import '../../home/home_cell_style/video_simple_cell.dart'; +import 'video_cache_logic.dart'; + +class VideoCacheCell extends StatefulWidget { + final VideoCacheLogic logic; // 注册局部刷新回调用 + final VideoModel model; + final MediaStyle style; // 影视 / 抖音 / 动漫 / 短剧 + final bool isEditing; // 缓存编辑状态 + final GestureTapCallback? onCacheTap; + + /// 点卡片进播放页。必须交给内层 [VideoSimpleCell]——它自带手势且在更深一层, + /// 只在外面套 GestureDetector 的话手势竞技场里赢的是它,跳转会走它默认的那套(按时长分长/短视频) + final GestureTapCallback? onTap; + + const VideoCacheCell({ + super.key, + required this.logic, + required this.model, + required this.style, + this.isEditing = false, + this.onCacheTap, + this.onTap, + }); + + @override + State createState() => _VideoCacheCellState(); +} + +class _VideoCacheCellState extends State { + /// 覆盖层状态切换动画时长:编辑态/缓存按钮/开始结束/勾选态统一用它 + static const _switchDuration = Duration(milliseconds: 200); + + VideoModel get model => widget.model; + + bool get isDownloading => model.isDownloading; + bool get isFinished => model.progress >= 1; + + String get statusText { + if (isFinished) return "已完成"; + if (isDownloading) return "下载中..."; + return "已暂停"; + } + + @override + void initState() { + super.initState(); + // 向 logic 注册局部刷新:下载回调更新 model 后只 setState 本 cell,不再整页 update() + widget.logic.registerCellRefresher(model, _refresh); + } + + @override + void didUpdateWidget(covariant VideoCacheCell oldWidget) { + super.didUpdateWidget(oldWidget); + // 切换到不同视频时换绑刷新回调。 + // 用 realVideoUrl 而非 id 比较:本地缓存记录的 id 可能都是 "-1",比不出差异会漏换绑 + if (oldWidget.model.realVideoUrl != model.realVideoUrl) { + widget.logic.unregisterCellRefresher(oldWidget.model, _refresh); + widget.logic.registerCellRefresher(model, _refresh); + } + } + + @override + void dispose() { + widget.logic.unregisterCellRefresher(model, _refresh); + super.dispose(); + } + + /// logic 的下载回调命中本 cell 的 url 时触发:model 已被 logic 更新,这里只需重建 + void _refresh() { + if (mounted) setState(() {}); + } + + void _toggleSelected() { + model.isSelected = !model.isSelected; + setState(() {}); + } + + /// 短剧按集卖,而本地记录里的播放地址是当初解锁时拿到的,权益到期后它照样能用 + /// (m3u8 接口只认登录 token,不认单集权益)。**离线播放刻意不拦**——下载就是为了离线看, + /// 点卡片直接放本地文件(见 VideoCachePage._openVideo);这里只管两个会往外扩散的动作: + /// 导出相册、续下载 + Future _ensureUnlocked() async { + if (widget.style != MediaStyle.Drama) return true; + final episode = await DramaService.fetchEpisode(model.subid); + if (episode?.canPlay == true) return true; + showToast("该剧集权益已过期,请重新解锁"); + return false; + } + + void _saveToAlbum() async { + if (!await _ensureUnlocked()) return; + VideoSaveUtil.instance.convertVideoMp4( + model.realVideoUrl, + loadInfo: DownloadInfo(status: "1", localPath: model.localPath), + isShowLoading: true, + ); + } + + void _onCacheTap() async { + if (!await _ensureUnlocked()) return; + widget.onCacheTap?.call(); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + Container( + decoration: BoxDecoration( + color: const Color(0xff151515), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildBody()), + _buildStatus(), + ], + ), + ), + _buildOverlay(), + ], + ); + } + + /// cell 主体:动漫用 ACG 版本,其它(含短剧)用通用视频 cell + Widget _buildBody() { + if (widget.style == MediaStyle.Cartoon) { + return VideoACGCacheCell(model: model); + } + final isDrama = widget.style == MediaStyle.Drama; + return VideoSimpleCell( + videoModel: model, + onTap: widget.onTap, + textLines: 1, + imgBorderRadius: BorderRadius.circular(8), + //短剧和「热门短剧」橱窗一个样式:标题是剧名,封面右下角标本条是第几集; + //按集卖,整部剧标一个金币/VIP 角标对不上,跟橱窗一样关掉 + coverRightText: isDrama ? '第${model.episodeNo ?? 1}集' : null, + showLevelIcon: !isDrama, + ); + } + + /// 状态文案 + 保存按钮 + 进度条 + Widget _buildStatus() { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 3), + child: Row( + children: [ + // Expanded + ellipsis:吸收富余宽度,抖音 3 列窄格里不再右溢出 + Expanded( + child: Text( + statusText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 11, color: Color(0xffd43f61), height: 1.5), + ), + ), + // 仅下载完成且非动漫显示"保存到相册",避免下载中误触 + 右侧溢出 + if (widget.style != MediaStyle.Cartoon && isFinished) ...[ + 6.sizeBoxW, + InkWell( + enableFeedback: false, + onTap: _saveToAlbum, + child: const Text( + "保存到相册", + maxLines: 1, + style: TextStyle( + fontSize: 11, + color: Color(0xFFF68804), + height: 1.5, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ], + ), + ), + SizedBox( + height: 14, + child: Opacity( + opacity: isFinished ? 0 : 1, + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: model.progress, + backgroundColor: const Color(0xff727272), + color: const Color(0xffe75160), + ), + ), + ), + 5.sizeBoxW, + // 固定宽度,避免百分比数字位数变化导致进度条右边跳动 + SizedBox( + width: 46, + child: Text( + "${(model.progress * 100).toStringAsFixed(1)}%", + textAlign: TextAlign.right, + style: const TextStyle( + fontSize: 11, color: Color(0xffaab2b7), height: 1.2), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + /// 覆盖层三态切换(编辑勾选 / 缓存按钮 / 下载完成隐藏):淡入淡出,不再硬跳。 + /// 三个分支 runtimeType 各不相同,AnimatedSwitcher 自动识别为切换; + /// 而分支内部的 isDownloading / selected 变化 type 与 key 都不变,交给各自内层动画,蒙层不跟着闪。 + Widget _buildOverlay() { + return AnimatedSwitcher( + duration: _switchDuration, + // 显式 expand:改造前蒙层是 StackFit.expand 的直接 child(紧约束铺满 cell), + // 套 AnimatedSwitcher 后默认布局会变成松约束按内容撑开,这里保持原来的铺满行为 + layoutBuilder: (currentChild, previousChildren) => Stack( + fit: StackFit.expand, + children: [ + // 正在淡出的旧层要屏蔽点击:它还在树里,否则切换的这 200 毫秒内 + // 可能点到已经切走的按钮(刚进编辑模式却点中"开始缓存") + ...previousChildren.map((c) => IgnorePointer(child: c)), + if (currentChild != null) currentChild, + ], + ), + child: () { + if (widget.isEditing) return _buildSelectLayer(); + if (isFinished) return const SizedBox.shrink(); + return _buildDownloadButton(); + }(), + ); + } + + /// 开始 / 结束缓存按钮 + Widget _buildDownloadButton() { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: .1), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: GestureDetector( + onTap: _onCacheTap, + child: Container( + margin: const EdgeInsets.only(bottom: 72), + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(10), + ), + // 开始/结束 图标与文案切换:只动这一行,外层蒙层与按钮底不动。 + // 两种文案都是 4 个字 + 同宽图标,切换期间两者叠在 Stack 里不会撑宽按钮 + child: AnimatedSwitcher( + duration: _switchDuration, + child: Row( + key: ValueKey(isDownloading), + mainAxisSize: MainAxisSize.min, // 按内容收窄,不再固定 120 宽 + children: [ + Image.asset( + (isDownloading ? "cache_stop.webp" : "cache_start.webp") + .videoPath, + width: 10, + fit: BoxFit.cover, + ), + 6.sizeBoxW, + Text( + isDownloading ? "结束缓存" : "开始缓存", + style: const TextStyle(fontSize: 12, color: Colors.white), + ), + ], + ), + ), + ), + ), + ), + ); + } + + /// 编辑态勾选层 + Widget _buildSelectLayer() { + final selected = model.isSelected; + return InkWell( + enableFeedback: false, + onTap: _toggleSelected, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: const Color(0x99707070), + ), + child: Center( + // 勾选态切换:淡入 + 轻微放大,点击有即时反馈(scale 从 .7 起,不从 0 免得过于夸张) + child: AnimatedSwitcher( + duration: _switchDuration, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: .7, end: 1.0).animate(animation), + child: child, + ), + ), + child: Image.asset( + (selected ? "cache_selected.webp" : "cache_unselected.webp") + .videoPath, + key: ValueKey(selected), + width: 40, + height: 40, + ), + ), + ), + ), + ); + } +} + +class VideoACGCacheCell extends StatelessWidget { + final VideoModel model; + + const VideoACGCacheCell({super.key, required this.model}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: NetworkImageLoader( + imageUrl: model.cover ?? "", + imgBorderRadius: BorderRadius.circular(12), + borderRadius: 4, + ), + ), + 4.sizeBoxH, + Text( + model.title ?? '', + style: const TextStyle(fontSize: 14, color: Colors.white), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + ), + 4.sizeBoxH, + Text( + '${model.updateDesc} · 共${model.totalEpisode ?? 0}话', + textAlign: TextAlign.left, + style: TextStyle( + fontSize: 12, + color: + model.updateStatus == 2 ? Color(0xff757575) : Color(0xffEEC76B), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } +} diff --git a/lib/hj_page/mine/mine_video_cache/video_cache_logic.dart b/lib/hj_page/mine/mine_video_cache/video_cache_logic.dart new file mode 100644 index 0000000..eec9a1e --- /dev/null +++ b/lib/hj_page/mine/mine_video_cache/video_cache_logic.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../../../hj_model/video_model.dart'; +import '../../../tools_base/video_download/video_cache_store.dart'; +import '../../../tools_base/video_download/video_download_manager.dart'; + +class VideoCacheLogic extends GetxController with GetTickerProviderStateMixin { + /// 删除退场动画时长:cell 缩放淡出播完才真删数据,page 侧动画时长与此保持一致 + static const removeAnimDuration = Duration(milliseconds: 260); + + //顺序与 _cacheMap 一致 + final tabStyles = const [ + MediaStyle.Video, + MediaStyle.ShortVideo, + MediaStyle.Cartoon, + MediaStyle.Drama + ]; + + late final tabTitles = tabStyles.map((e) => e.cacheTabTitle).toList(); + + //在 onInit 里赋值而不是写成 late final 惰性初始化:那样 onClose 里的 dispose + //会成为「首次访问」,在控制器已销毁时才去 createTicker + late final TabController tabCtr; + + /// 各类型缓存列表;短剧按**集**存,一部剧会有多条记录 + final _cacheMap = { + MediaStyle.Video: [], + MediaStyle.ShortVideo: [], + MediaStyle.Cartoon: [], + MediaStyle.Drama: [], + }; + + List listOf(MediaStyle style) => _cacheMap[style] ?? const []; + + List get allVideos => + [for (final list in _cacheMap.values) ...list]; + + bool isLoading = true; + bool isEditing = false; + + /// 删除流程进行中,防止 await 期间重复点「删除」 + bool _isDeleting = false; + + /// 正在播退场动画的 item:动画期间仍留在列表里,播完才真删数据 + final _removingItems = {}; + + bool isRemoving(VideoModel model) => _removingItems.contains(model); + + /// taskKey -> 对应 cell 的局部刷新回调。 + /// 下载进度不再触发整页 update(),改为只 setState 命中 url 的 cell(局部刷新)。 + /// 用 Set 兜底同一 url 出现在多个 cell 的极端情况。 + final _cellRefreshers = >{}; + + /// 下载回调:Android isolate / iOS task 两端都靠它回传,命中哪条只刷哪个 cell + late final _callback = DownloadCallback( + success: (url) async { + final model = _findByUrl(url); + model?.loadProgress = "100.00"; + // 实时完成时 localPath 还没回填(只在进页面 searchInfo 填过一次,那会儿还没下完), + // 这里补查一次写回,否则点"保存到相册"会因 localPath 为空报"视频文件不存在" + final info = await VideoDownloadManager.instance.searchInfo(url: url); + if (info != null) model?.localPath = info.localPath; + _refreshCells(url); + }, + fail: (url, error) { + _findByUrl(url)?.isLoaderRunning = "0"; + showToast("缓存加载失败"); + _refreshCells(url); + }, + progress: (url, progress) { + _findByUrl(url)?.loadProgress = progress; + _refreshCells(url); + }, + ); + + /// cell 挂载时注册自己的局部刷新回调 + void registerCellRefresher(VideoModel model, VoidCallback refresh) { + _cellRefreshers + .putIfAbsent(VideoDownloadManager.taskKey(model.realVideoUrl), () => {}) + .add(refresh); + } + + /// cell 卸载 / 换绑视频时注销 + void unregisterCellRefresher(VideoModel model, VoidCallback refresh) { + final key = VideoDownloadManager.taskKey(model.realVideoUrl); + final set = _cellRefreshers[key]; + if (set == null) return; + set.remove(refresh); + if (set.isEmpty) _cellRefreshers.remove(key); + } + + /// 精准刷新某 url 对应的 cell(替代原来下载回调里的全页 update()) + void _refreshCells(String url) { + final set = _cellRefreshers[VideoDownloadManager.taskKey(url)]; + if (set == null) return; + for (final refresh in Set.of(set)) { + refresh(); + } + } + + /// 按 taskKey 在列表里找到对应 model(url 带 token/cdn,直接比会漏匹配) + VideoModel? _findByUrl(String url) { + final key = VideoDownloadManager.taskKey(url); + for (final item in allVideos) { + if (VideoDownloadManager.taskKey(item.realVideoUrl) == key) return item; + } + return null; + } + + @override + void onInit() { + super.onInit(); + tabCtr = TabController(length: tabStyles.length, vsync: this); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadData()); + } + + void _loadData() async { + try { + for (final entry in _cacheMap.entries) { + entry.value + ..clear() + ..addAll( + await VideoCacheStore.instance.getMovieCacheVideoList(entry.key)); + } + // 本地记录只有 url,进度/暂停态要逐条问下载器;顺带把 _callback 挂上去 + for (final item in allVideos) { + //条目多时这个循环很慢,用户中途退页面就别再往下问: + //onClose 已经把回调摘干净了,这里再 searchInfo 会把 _callback 重新挂回单例,留下悬挂回调 + if (isClosed) return; + final info = await VideoDownloadManager.instance.searchInfo( + url: item.realVideoUrl, + callback: _callback, + ); + if (info != null) { + item.localPath = info.localPath; + item.loadProgress = info.progress; + item.isLoaderRunning = info.isLoaderRunning; + } + } + } catch (e) { + debugLog(e); + } + isLoading = false; + if (!isClosed) update(); + } + + /// 右上角按钮:非编辑态进编辑,编辑态执行删除 + void editEvent() { + if (_isDeleting) return; // 删除动画/落盘期间不响应重复点击 + if (isEditing) { + _deleteSelected(); + return; + } + //进编辑先清掉上轮残留的勾选,否则再次进编辑会显示脏选中态 + //(删除失败时未删项、或退场动画那 260ms 里新勾的项,都会残留 isSelected) + for (final item in allVideos) { + item.isSelected = false; + } + isEditing = true; + update(); + } + + /// 先标记被选中项播退场动画,动画播完再真删,避免 cell 硬闪消失 + void _deleteSelected() async { + final deleteList = allVideos.where((e) => e.isSelected).toList(); + if (deleteList.isEmpty) { + isEditing = false; + update(); + return; + } + + _isDeleting = true; + _removingItems.addAll(deleteList); + update(); + try { + await Future.delayed(removeAnimDuration); + for (final item in deleteList) { + await VideoDownloadManager.instance.delete(item.realVideoUrl); + } + await VideoCacheStore.instance.removeVideoListNoType(deleteList); + for (final list in _cacheMap.values) { + list.removeWhere(deleteList.contains); + } + } catch (e) { + debugLog(e); + showToast("删除失败"); + } finally { + // 必须复位,否则一次异常就把页面卡死:_isDeleting 会让「删除」按钮永久失效, + // _removingItems 残留会让那几个 cell 一直保持透明 + _removingItems.clear(); + isEditing = false; + _isDeleting = false; + // 删除期间用户可能已退页面(GetBuilder 会 dispose logic),落盘照做但别再刷 UI + if (!isClosed) update(); + } + } + + /// 开始 / 暂停下载 + void toggleDownload(VideoModel model) async { + if (model.isDownloading) { + await VideoDownloadManager.instance.pause(model.realVideoUrl); + model.isLoaderRunning = "0"; + } else { + // 开始新任务前,先暂停其它正在下载的任务(同时只允许一个下载) + for (final item in allVideos) { + if (item.isDownloading) { + await VideoDownloadManager.instance.pause(item.realVideoUrl); + item.isLoaderRunning = "0"; + } + } + final result = await VideoDownloadManager.instance.download( + url: model.realVideoUrl, + callback: _callback, + ); + if (result != null) { + debugLog("download movie file: $result"); + } else { + model.isLoaderRunning = "1"; + } + } + update(); + } + + @override + void onClose() { + // 只注销本页自己注册的那个 _callback,绝不能清光单例里的全部回调: + // 那会连短视频页 DownloadButton 等别处组件的回调一起清掉, + // 表现为「短视频页点下载 → 来缓存页看一眼 → 退回去,那个按钮的进度就再也不动了」。 + // _callback 是同一个实例注册在多个 url 上(_loadData 逐个 searchInfo 都传了它),故逐个摘除 + for (final item in allVideos) { + VideoDownloadManager.instance + .removeCallback(item.realVideoUrl, _callback); + } + _cellRefreshers.clear(); + tabCtr.dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/mine/mine_video_cache/video_cache_page.dart b/lib/hj_page/mine/mine_video_cache/video_cache_page.dart new file mode 100644 index 0000000..7456e07 --- /dev/null +++ b/lib/hj_page/mine/mine_video_cache/video_cache_page.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; +import 'package:hgdj/tools_base/widget/stagger_in_item.dart'; + +import '../../../hj_model/drama_media_info.dart'; +import '../../../hj_model/video_model.dart'; +import '../../../tools_base/indicator/custom_tab_indicator.dart'; +import '../../drama/drama_detail_page.dart'; +import '../../video/simple_video_player_page.dart'; +import 'video_cache_cell.dart'; +import 'video_cache_logic.dart'; + +class VideoCachePage extends StatelessWidget { + const VideoCachePage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: VideoCacheLogic(), + global: false, + builder: (logic) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: Text("下载缓存"), + actions: [ + InkWell( + enableFeedback: false, + onTap: () => logic.editEvent(), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 12), + alignment: Alignment.centerRight, + child: Text( + logic.isEditing ? "删除" : "编辑", + style: const TextStyle( + color: Color(0xff757575), + fontSize: 14, + ), + ), + ), + ), + const SizedBox(width: 14), + ], + ), + body: logic.isLoading + ? LoadingCenterWidget() + : Column( + children: [ + Container( + color: Theme.of(context).appBarTheme.backgroundColor, + child: TabBar( + //tab 等分整宽,不滚动 + indicator: CustomIndicator(isGradient: true), + indicatorWeight: 1, + unselectedLabelColor: Color(0x8CFFFFFF), + unselectedLabelStyle: TextStyle(fontSize: 14), + labelStyle: TextStyle( + fontSize: 14, fontWeight: FontWeight.w500), + labelColor: Color(0xE5FFFFFF), + tabs: logic.tabTitles + .map( + (e) => Padding( + padding: EdgeInsets.fromLTRB(0, 5, 0, 5), + child: Text(e), + ), + ) + .toList(), + controller: logic.tabCtr, + ), + ), + Expanded( + child: TabBarView( + controller: logic.tabCtr, + children: [ + // keepAlive:切 tab 不重建,保留滚动位置,也避免入场动画反复重播 + for (final style in logic.tabStyles) + _buildGrid(logic, style).keepAlive, + ], + ), + ) + ], + ), + ); + }, + ); + } + + Widget _buildGrid(VideoCacheLogic logic, MediaStyle style) { + final dataArr = logic.listOf(style); + if (dataArr.isEmpty) { + return CErrorWidget(); + } + return GridView.builder( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + //短剧跟「热门短剧」橱窗同规格:2 列 168/266 + crossAxisCount: + style == MediaStyle.ShortVideo || style == MediaStyle.Cartoon + ? 3 + : 2, + crossAxisSpacing: 6, + mainAxisSpacing: 12, + childAspectRatio: switch (style) { + MediaStyle.ShortVideo => 191 / 390, + MediaStyle.Cartoon => 191 / 420, + MediaStyle.Drama => 168 / 266, + _ => 191 / 210, + }, + ), + itemCount: dataArr.length, + itemBuilder: (context, int index) { + final videoModel = dataArr[index]; + return StaggerInItem( + // key 用 model 对象身份而非 index:删除后剩余项 Element 按 key 复用、 + // 各自 State 保留,既不重播入场动画(否则整屏闪一下),也不会串味。 + // 用 ObjectKey 而不是 url/taskKey:同一 url 可能对应多条记录(见 _cellRefreshers 的注释), + // 那样会撞成重复 key 直接抛 Duplicate keys;对象身份天然唯一且删除后不变 + key: ObjectKey(videoModel), + index: index, + child: _buildCell(logic, videoModel, style), + ); + }, + ); + } + + /// 单个缓存 cell:被标记删除时缩放 + 淡出(时长与 logic 一致,播完 logic 才真删数据) + Widget _buildCell( + VideoCacheLogic logic, VideoModel videoModel, MediaStyle style) { + final isRemoving = logic.isRemoving(videoModel); + // 退场中屏蔽点击:opacity 到 0 也照样命中手势,否则那 260 毫秒里 + // 点到看不见的 cell 会跳进播放页 + return IgnorePointer( + ignoring: isRemoving, + child: AnimatedOpacity( + opacity: isRemoving ? 0 : 1, + duration: VideoCacheLogic.removeAnimDuration, + curve: Curves.easeOut, + child: AnimatedScale( + scale: isRemoving ? .8 : 1, + duration: VideoCacheLogic.removeAnimDuration, + curve: Curves.easeOut, + child: GestureDetector( + //动漫 cell 自身没有手势,靠这一层接管;其余 cell 内层 VideoSimpleCell 也收了同一个回调 + onTap: () => _openVideo(videoModel, style), + child: VideoCacheCell( + logic: logic, + model: videoModel, + style: style, + isEditing: logic.isEditing, + onCacheTap: () => logic.toggleDownload(videoModel), + onTap: () => _openVideo(videoModel, style), + ), + ), + ), + ), + ); + } + + /// 点缓存卡片进播放页 + void _openVideo(VideoModel videoModel, MediaStyle style) { + //下载就是为了离线看:有本地文件就直接放,不进二级页——二级页是纯网络播放器 + //(VideoPlayerBaseLogic.initPlayer 只认 network),进那儿等于没用上下好的文件,断网还打不开 + if (style == MediaStyle.Drama && videoModel.localPath?.isNotEmpty == true) { + _playLocal(videoModel, + '${videoModel.title ?? ""} 第${videoModel.episodeNo ?? 1}集'); + return; + } + //没下完(或只有下载态没落到文件)的没有本地文件可放,进二级页在线看这一集: + //本地记录只留了剧 id 和分集 id,短剧不在 /vid/info 里,走 pushToVideoPage 那套只会拿剧 id 去查视频详情 + if (style == MediaStyle.Drama) { + Get.to(() => DramaDetailPage( + drama: DramaMediaInfo() + ..id = videoModel.id + ..title = videoModel.title + ..verticalCover = videoModel.cover, + initialContentId: videoModel.subid, + )); + return; + } + //id 为 -1 的是老版本落的记录,没有详情可拉,只能本地放 + if (videoModel.id == "-1") { + _playLocal(videoModel, videoModel.title ?? ""); + return; + } + pushToVideoPage( + videoModel: videoModel, isCartoon: videoModel.videoType == 1); + } + + /// 用本地文件播([SimpleVideoPlayerLogic] 拿到 localPath 就走 PlayerFactory.file) + void _playLocal(VideoModel videoModel, String title) => + Get.to(SimpleVideoPlayerPage( + localPath: videoModel.localPath, + videoUrl: videoModel.realVideoUrl, + title: title, + )); +} diff --git a/lib/hj_page/mine/mine_vip/charge_user_header.dart b/lib/hj_page/mine/mine_vip/charge_user_header.dart new file mode 100644 index 0000000..84a2ff4 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/charge_user_header.dart @@ -0,0 +1,199 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../widgets/gradient_text.dart'; + +class UserChargeView extends StatelessWidget { + final bool isFromCoin; // true: 来源金币充值, vip时间为灰色 + /// true:旧版文案/配色(会员中心 A / DISABLED) + final bool classic; + + const UserChargeView( + {super.key, this.isFromCoin = false, this.classic = false}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (_, provider, __) { + if (classic) return _buildClassic(provider); + return _buildModern(provider); + }, + ); + } + + Widget _buildModern(GlobalStore provider) { + final vipIcon = provider.meInfo?.vipImageName ?? ''; + return Row( + children: [ + NetworkImageLoader( + imageUrl: provider.meInfo?.portrait ?? '', + width: 60, + height: 60, + borderRadius: 30, + ), + 12.sizeBoxW, + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + provider.meInfo == null + ? '未知' + : provider.meInfo?.name?.substring( + 0, min(provider.meInfo?.name?.length ?? 0, 9)) ?? + '', + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: const TextStyle( + fontSize: 18, + color: Color(0xffF6EEDC), + fontWeight: FontWeight.w600, + ), + ), + 8.sizeBoxW, + if (!provider.isVIP) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Color(0x14FFFFFF), width: 1), + ), + child: const Text( + '未开通', + style: TextStyle( + color: Color(0xff9A927C), + fontSize: 10, + height: 1.2, + ), + ), + ) + else if (vipIcon.isNotEmpty) + Image.asset(vipIcon, height: 20), + ], + ), + 4.sizeBoxH, + globalStore.isVIP + ? Consumer( + builder: (context, provider, child) { + if (isFromCoin) { + return Text( + '会员到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}', + style: TextStyle( + color: Color(0xff9A927C), + fontSize: 12, + ), + ); + } else { + return GradientText( + '会员到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}', + gradient: LinearGradient( + colors: [ + Color(0xffFFE8BE), + Color(0xffE6B764), + ], + ), + style: TextStyle(fontSize: 12), + ); + } + }, + ) + : Text( + '开通会员 · 解锁全站尊享特权', + style: TextStyle( + color: Color(0xff9A927C), + fontSize: 12, + ), + ), + ], + ) + ], + ).paddingSymmetric(horizontal: 16); + } + + Widget _buildClassic(GlobalStore provider) { + final vipIcon = provider.meInfo?.vipImageName ?? ''; + return Row( + children: [ + NetworkImageLoader( + imageUrl: provider.meInfo?.portrait ?? '', + width: 60, + height: 60, + borderRadius: 30, + ), + 12.sizeBoxW, + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + provider.meInfo == null + ? '未知' + : provider.meInfo?.name?.substring( + 0, min(provider.meInfo?.name?.length ?? 0, 9)) ?? + '', + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 18, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + 12.sizeBoxW, + if (provider.isVIP && vipIcon.isNotEmpty) + Image.asset(vipIcon, height: 20), + ], + ), + 4.sizeBoxH, + globalStore.isVIP + ? Consumer( + builder: (context, provider, child) { + if (isFromCoin) { + return Text( + '到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}', + style: TextStyle( + color: Color(0xff989898), + fontSize: 12, + ), + ); + } else { + return GradientText( + '到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}', + gradient: LinearGradient( + colors: [ + Color(0xffFFE8BE), + Color(0xffE6B764), + ], + ), + style: TextStyle(fontSize: 12), + ); + } + }, + ) + : Text( + '您还不是会员 开通会员 畅享特权', + style: TextStyle( + color: Color(0xff989898), + fontSize: 12, + ), + ), + ], + ) + ], + ).paddingSymmetric(horizontal: 16); + } +} diff --git a/lib/hj_page/mine/mine_vip/coupon_model.dart b/lib/hj_page/mine/mine_vip/coupon_model.dart new file mode 100644 index 0000000..85f2072 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/coupon_model.dart @@ -0,0 +1,56 @@ +class AICouponModel { + String? createTime; + String? expiredTime; + String? goodsDesc; + String? goodsName; + String? goodsOrigin; + int? goodsType; + int? goodsValue; + String? id; + int? status; + int? uid; + String? useTime; + + AICouponModel( + {this.createTime, + this.expiredTime, + this.goodsDesc, + this.goodsName, + this.goodsOrigin, + this.goodsType, + this.goodsValue, + this.id, + this.status, + this.uid, + this.useTime}); + + AICouponModel.fromJson(Map json) { + createTime = json['createTime']; + expiredTime = json['expiredTime']; + goodsDesc = json['goodsDesc']; + goodsName = json['goodsName']; + goodsOrigin = json['goodsOrigin']; + goodsType = json['goodsType']; + goodsValue = json['goodsValue']; + id = json['id']; + status = json['status']; + uid = json['uid']; + useTime = json['useTime']; + } + + Map toJson() { + final Map data = new Map(); + data['createTime'] = this.createTime; + data['expiredTime'] = this.expiredTime; + data['goodsDesc'] = this.goodsDesc; + data['goodsName'] = this.goodsName; + data['goodsOrigin'] = this.goodsOrigin; + data['goodsType'] = this.goodsType; + data['goodsValue'] = this.goodsValue; + data['id'] = this.id; + data['status'] = this.status; + data['uid'] = this.uid; + data['useTime'] = this.useTime; + return data; + } +} diff --git a/lib/hj_page/mine/mine_vip/mine_charge_coin_logic.dart b/lib/hj_page/mine/mine_vip/mine_charge_coin_logic.dart new file mode 100644 index 0000000..12c2d56 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/mine_charge_coin_logic.dart @@ -0,0 +1,67 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../../../hj_model/mine/exchange/recharge_list_model.dart'; +import '../../../hj_model/mine/exchange/recharge_type_list_model.dart'; +import 'online_pay_page.dart'; +import 'pay_order_source.dart'; + +class MineChargeCoinLogic extends GetxController { + /// 下单来源(透传 /mine/topay 的 sourcePage),由入口传入 + final PaySourcePage sourcePage; + + /// 下单埋点上下文(短剧付费墙要带 mediaId/contentId/checkoutContextId 做归因) + final PayOrderTrackInfo? orderTrack; + + MineChargeCoinLogic( + {this.sourcePage = PaySourcePage.unknown, this.orderTrack}); + + bool isInitLoading = true; // 首屏加载中 + RechargeListModel? model; // 金币充值档位列表 + RechargeTypeModel? selectedCoin; // 当前选中的金币档位 + + @override + void onReady() { + super.onReady(); + loadData(); + } + + // 拉取金币充值档位,默认选中第一档 + Future loadData() async { + final res = await MineService.getChatRechargeTypes(1); + isInitLoading = false; + model = res; + res?.list ??= []; + if (res?.list?.isNotEmpty == true) { + selectedCoin = res!.list!.first; + } + update(); + } + + // 选择金币档位 + void onSelectCoin(int index) { + selectedCoin = model?.list?[index]; + update(); + } + + // 去支付(购买金币) + void onGotoPay() { + if (selectedCoin == null) { + showToast("请选择产品"); + return; + } + if (selectedCoin!.rechargeTypeListUI.isEmpty) { + showToast("未配置支付方式,请联系客服"); + return; + } + Get.bottomSheet( + OnlinePayPage( + coinRcModel: selectedCoin, + orderTrack: (orderTrack ?? const PayOrderTrackInfo()) + .copyWith(sourcePage: sourcePage), + ), + isScrollControlled: true, + ); + } +} diff --git a/lib/hj_page/mine/mine_vip/mine_charge_coin_page.dart b/lib/hj_page/mine/mine_vip/mine_charge_coin_page.dart new file mode 100644 index 0000000..be3cee1 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/mine_charge_coin_page.dart @@ -0,0 +1,240 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../hj_utils/widget_util.dart'; +import '../../../routers/jump_router.dart'; +import '../make_money/mine_withdrawal_record_page.dart'; +import 'charge_user_header.dart'; +import 'mine_charge_coin_logic.dart'; +import 'pay_order_source.dart'; +import 'widgets/coin_item.dart'; + +//金币充值页面 +class MineChargeCoinPage extends StatelessWidget { + /// 下单来源,入口不传则 UNKNOWN(金币没有专属来源枚举,别拿会员的顶上) + final PaySourcePage sourcePage; + + const MineChargeCoinPage( + {super.key, this.sourcePage = PaySourcePage.unknown}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MineChargeCoinLogic(sourcePage: sourcePage), + global: false, // 多入口各自独立:叠栈时别复用上一个页面的 controller(来源会串成上一次的) + builder: (controller) { + return Scaffold( + body: () { + if (controller.isInitLoading) return const LoadingCenterWidget(); + if (controller.model == null) + return CErrorWidget(retryOnTap: () => controller.loadData()); + return Column( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + // 头像 + 余额卡片:随金币列表上滑,自带折叠淡出动画 + SliverAppBar( + pinned: true, + backgroundColor: Color(0xff0F0F0F), + surfaceTintColor: Colors.transparent, + expandedHeight: kToolbarHeight + 200, + title: Text( + '金币充值', + style: textStyle(16, Colors.white, FontWeight.w600), + ), + actions: [ + InkWell( + enableFeedback: false, + onTap: () => + Get.to(RecordsPage(RecordType.recharge)), + child: Text( + '充值记录', + style: TextStyle( + color: Color(0xff666666), fontSize: 16.sp), + ), + ), + 18.w.sizeBoxW + ], + flexibleSpace: FlexibleSpaceBar( + background: SafeArea( + bottom: false, + child: Column( + children: [ + kToolbarHeight.sizeBoxH, + UserChargeView(isFromCoin: true), + 18.sizeBoxH, + _buildWallet(), + ], + ), + ), + ), + ), + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid.builder( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: 111 / 138, + ), + itemCount: controller.model?.list?.length ?? 0, + itemBuilder: (BuildContext context, int index) { + final model = controller.model!.list![index]; + final isSelected = + controller.selectedCoin?.id == model.id; + return CoinItem( + model, + onTap: () => controller.onSelectCoin(index), + isSelected: isSelected, + ); + }, + ), + ), + SliverToBoxAdapter(child: 18.sizeBoxH), + SliverToBoxAdapter( + child: EasyRichText( + '*如提示【交易失败】【账户风险】等,可重新发起订单,或在15分钟后重试支付。如支付未到账,请反馈客服订单号', + defaultStyle: + TextStyle(color: Color(0xff666666), fontSize: 12), + patternList: [ + EasyRichTextPattern( + targetString: '反馈客服订单号', + style: TextStyle( + color: Color(0xffFFD460), + fontWeight: FontWeight.w500), + recognizer: TapGestureRecognizer() + ..onTap = () => pushToCustomService(), + ) + ], + ).paddingSymmetric(horizontal: 16), + ), + ], + ), + ), + _buildBottomBar(controller), + ], + ); + }(), + ); + }, + ); + } + + // 底部支付按钮 + 客服入口 + Widget _buildBottomBar(MineChargeCoinLogic controller) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => controller.onGotoPay(), + child: Container( + height: 44, + margin: EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(3)), + alignment: Alignment.center, + child: Text( + '¥${controller.selectedCoin?.moneyYuan ?? 0}/立即支付', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + ), + ), + 12.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('支付问题反馈,点击联系 ', + style: textStyle(12, Color(0xffBFBFC1), FontWeight.w400)), + GestureDetector( + onTap: () { + pushToCustomService(); + }, + child: Text('在线客服', + style: textStyle(12, Color(0xffFFD460), FontWeight.w400)), + ) + ], + ), + // 垫上虚拟导航栏高度,避免底部内容被遮挡(edge-to-edge) + (16 + screen.paddingBottom).sizeBoxH, + ], + ); + } + + // 我的金币余额卡片 + Widget _buildWallet() { + return Container( + margin: EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.centerLeft, + padding: EdgeInsets.fromLTRB(16, 18, 16, 18), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .1), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '我的金币余额', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500), + ), + 8.sizeBoxH, + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset('coin_icon.webp'.mineImgPath, width: 36), + 4.sizeBoxW, + Consumer(builder: (_, store, __) { + final wallet = store.wallet; + final total = (wallet?.amount ?? 0) + (wallet?.income ?? 0); + return Text( + "$total", + style: TextStyle( + color: Color(0xffFFD460), + fontSize: 32, + fontWeight: FontWeight.w600), + ); + }), + Spacer(), + InkWell( + enableFeedback: false, + onTap: () => + Get.to(RecordsPage(RecordType.bill), opaque: false), + child: Container( + alignment: Alignment.center, + height: 30, + width: 90, + decoration: BoxDecoration( + color: Color(0xffFFD460), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '余额明细', + style: TextStyle(color: Color(0xff3D3D3D), fontSize: 14), + ), + ), + ) + ], + ), + ], + )); + } +} diff --git a/lib/hj_page/mine/mine_vip/mine_charge_vip_logic.dart b/lib/hj_page/mine/mine_vip/mine_charge_vip_logic.dart new file mode 100644 index 0000000..94c3f18 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/mine_charge_vip_logic.dart @@ -0,0 +1,224 @@ +import 'package:carousel_slider/carousel_controller.dart'; +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/alert/vip_guide/guide_manager.dart'; +import 'package:hgdj/alert/vip_guide/timed_popup_manager.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../alert/vip_guide/guide_countdown_dialog.dart'; +import '../../../alert/vip_guide/guide_config.dart'; +import 'online_pay_page.dart'; +import 'pay_order_source.dart'; +import 'vip_card_analytics.dart'; +import 'vip_card_item.dart'; +import 'vip_product_manager.dart'; +import 'vip_support_model.dart'; + +/// 会员卡页/购买弹窗的页面级 Logic:只管「当前选中卡 / 轮播 / 支付触发」。 +/// 会员卡列表数据(请求/缓存/组装/选卡)统一在 [VipProductManager],UI 用 Consumer 消费。 +class MineChargeVipLogic extends GetxController { + /// 局部刷新 id:只有「跟随选中卡变化」的区域订阅它(卡片选中态 / 价格 / 权益区 / 支付按钮), + /// 滑卡时不必整页重建(背景图、轮播本体、用户信息条、AppBar 都跟选中卡无关) + static const kSelection = 'vip_selection'; + + /// 跳转指定选中的会员卡ID(构造传入,替代 Get.arguments) + final String? vipID; + + /// 下单来源(透传 /mine/topay 的 sourcePage) + final PaySourcePage sourcePage; + + /// 播放页拉起时带上在看的那条内容 id,服务端按它归因;会员中心进来为空 + final String? videoId; + + /// 入口透传的整份下单上下文,字段比 [videoId] 全时以它为准(短剧付费墙带 mediaId/contentId/checkoutContextId) + final PayOrderTrackInfo? orderTrack; + + MineChargeVipLogic( + {this.vipID, + this.sourcePage = PaySourcePage.vipCenter, + this.videoId, + this.orderTrack}); + + /// 当前选中的会员卡(页面级状态,多入口各自独立) + VipProductModel? currentProductModel; + + /// 引导弹窗点「立即开通」指定的卡ID,优先级高于构造传入的 vipID(用户显式选择) + String? _guideCardId; + + /// 会员卡轮播控制器(页面级,两个入口不能共享) + late final pageCtr = CarouselSliderController(); + + /// 本页 VIP 卡片统计会话 + VipCardAnalyticsSession? _analytics; + + //轮播视口占比 / 高度:B 组固定卡面 148×157 + 横向间距 10;高度含角标与底部发光 + double get itemRatio => vipProductManager.isNewVipUi + ? (148 + 10) / screen.screenWidth + : 137.6 / screen.screenWidth; + + double get cardHeight => vipProductManager.isNewVipUi + ? 189 // 8角标 + 157卡面 + 24底部 Glow + : (screen.screenWidth * itemRatio) * 150 / 110.6; + + /// 进页面是否强制拉最新:会员中心页 true(每次刷新),购买弹窗覆写为 false(优先用缓存) + bool get preferFreshData => true; + + /// 进入本页是否触发「优惠倒计时」引导弹窗(VIP_CENTER)。购买弹窗(BuyVipAlertLogic)覆写为 false,避免叠弹。 + bool get enableEntryGuidePopup => true; + + @override + void onReady() { + super.onReady(); + PreSaleProvider().refreshConfig(); + loadCards(force: preferFreshData); + if (enableEntryGuidePopup) _tryShowEntryGuide(); + } + + @override + void onClose() { + // 页面/弹窗销毁 → GetBuilder 按 tag 走 Get.delete → onDelete → 这里补报 CLOSE + _analytics?.reportCloseWithoutPurchaseIfNeeded(); + _analytics = null; + super.onClose(); + } + + /// VIP_CENTER:开关允许时**每次**进会员页都弹优惠倒计时引导 + /// (含从别的引导弹窗点「开通」跳进来的那次,运营要追单,不做去重)。 + void _tryShowEntryGuide() { + TimedPopupManager().trigger( + canShow: () => GuideManager().canShow(GuideScene.vipCenter), + onShow: () => GuideCountdownDialog.show( + scene: GuideScene.vipCenter, + // 已在会员页:关弹窗 + 就地选中弹窗配置的那张卡,让底部支付按钮直接对上该卡价格 + onConfirm: (card) { + Get.back(result: true); // 对齐 show() 的返回语义:true=点了开通 + _selectCard(card); + }, + ), + ); + } + + /// 选中并滚到指定会员卡:只驱动轮播,选中态由 [onPageChanged] 统一更新(和点卡片一致)。 + /// 列表还在加载时轮播没渲染、动不了,先记下 id 交给 [_syncSelection] 落位 + void _selectCard(VipProductModel card) { + _guideCardId = card.productID; + if (vipProductManager.isLoading || !pageCtr.ready) return; + final index = vipProductManager.vipCards + .indexWhere((e) => e.productID == card.productID); + if (index >= 0) pageCtr.animateToPage(index, curve: Curves.fastOutSlowIn); + } + + /// 拉取会员卡列表并同步选中态 + Future loadCards({bool force = false}) async { + await vipProductManager.loadVipCards(force: force); + _syncSelection(); + } + + /// 数据就绪后按优先级确定默认选中卡,并滚动到对应位置;UI 展示后再上报曝光 + void _syncSelection() { + currentProductModel = vipProductManager.defaultVipCard( + vipID: _guideCardId ?? vipID, current: currentProductModel); + final pid = currentProductModel?.productID; + final index = pid == null + ? -1 + : vipProductManager.vipCards.indexWhere((e) => e.productID == pid); + // 会话尽早创建:避免仅在 post-frame 里建,用户快速返回时 onClose 拿不到 session、CLOSE 丢报。 + // 必须等有卡数据再建:首次请求失败时卡列表还是空,会把「无实验」快照锁进 session, + // 之后错误页重试成功也不会上报(快照 late final 不可变)。 + if (vipProductManager.vipCards.isNotEmpty) + _analytics ??= VipCardAnalyticsSession(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isClosed) return; + // 卡列表为空时页面渲染的是错误页,轮播压根没 build,controller 没 attach, + // 而 jumpToPage 内部是 _state!.pageController!.page! 三层空断言,会直接崩 + if (index >= 0 && pageCtr.ready) pageCtr.jumpToPage(index); + // 曝光必须在 UI 实际展示后上报 + _analytics?.reportPageViewAfterPaint(); + _analytics?.reportProductImpression(currentProductModel, + afterPaint: true); + }); + update(); + } + + Widget instanceChildItem(int index) { + final data = vipProductManager.vipCards; + if (index > data.length - 1) { + return SizedBox( + height: cardHeight, + width: screen.screenWidth, + ); + } + final model = data[index]; + final card = VipCardItem( + model, + isSelect: currentProductModel?.productID != null && + currentProductModel?.productID == model.productID, + callback: () { + pageCtr.animateToPage(index, curve: Curves.fastOutSlowIn); + }, + ); + // B 组:卡槽 = 148 + 右侧 10 间距 + if (vipProductManager.isNewVipUi) { + return Padding( + padding: const EdgeInsets.only(right: 10), + child: Align(alignment: Alignment.centerLeft, child: card), + ); + } + return card; + } + + /// 滑动切卡 + onPageChanged(int index) { + final data = vipProductManager.vipCards; + if (data.isEmpty) return; + // 轮播 itemCount = 卡数+2(尾部占位,让最后一张能滑到最左)。padEnds:false 时 PageView 的 + // 最大页码 = itemCount - 1/viewportFraction,B 组卡槽 158 在 <395 宽的机型上会四舍五入到 + // 占位项索引,直接取 dataSource[index] 会越界,这里钳到最后一张真实卡。 + currentProductModel = data[index.clamp(0, data.length - 1)]; + _analytics?.reportProductImpression(currentProductModel); + update([kSelection]); + } + + onInitiatePayAction() async { + if (currentProductModel == null) { + showToast("请选择充值的会员卡~"); + return; + } + if (currentProductModel!.rchgTypeUI.isEmpty) { + showToast("未配置支付方式,请联系客服"); + return; + } + await Get.bottomSheet( + OnlinePayPage( + vipProductModel: currentProductModel, + orderTrack: buildOrderTrack(), + ), + isScrollControlled: true, + ); + await PreSaleProvider().refreshConfig(); + update(); + } + + /// 下单埋点:来源 + 卡皮 session + ACTIVE 时的实验字段(普通卡 / 预售卡共用) + PayOrderTrackInfo buildOrderTrack() { + final base = orderTrack; + return PayOrderTrackInfo( + sourcePage: base?.sourcePage ?? sourcePage, + sourceRef: base?.sourceRef ?? videoId, + videoId: base?.videoId ?? videoId, + mediaId: base?.mediaId, + contentId: base?.contentId, + checkoutContextId: base?.checkoutContextId, + sessionId: base?.sessionId ?? _analytics?.sessionId, + experimentId: vipProductManager.isExperimentActive + ? (currentProductModel?.experimentId ?? + vipProductManager.experimentId) + : null, + experimentVariant: vipProductManager.isExperimentActive + ? (currentProductModel?.variant ?? vipProductManager.variant) + : null, + ); + } +} diff --git a/lib/hj_page/mine/mine_vip/mine_charge_vip_page.dart b/lib/hj_page/mine/mine_vip/mine_charge_vip_page.dart new file mode 100644 index 0000000..bf81f05 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/mine_charge_vip_page.dart @@ -0,0 +1,471 @@ +import 'dart:math' as math; + +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../routers/jump_router.dart'; +import '../../pre_sale/pre_sale_entry.dart'; +import '../make_money/mine_withdrawal_record_page.dart'; +import 'charge_user_header.dart'; +import 'mine_charge_vip_logic.dart'; +import 'pay_order_source.dart'; +import 'vip_card_item.dart'; +import 'vip_pay_button.dart'; +import 'vip_product_manager.dart'; +import 'vip_support_model.dart'; +import 'vip_ui_kit.dart'; + +class MineChargeVipPage extends StatefulWidget { + final String? vipID; //跳转指定选中的会员卡ID + /// 下单来源,默认会员中心 + final PaySourcePage sourcePage; + + const MineChargeVipPage({ + super.key, + this.vipID, + this.sourcePage = PaySourcePage.vipCenter, + }); + + @override + State createState() => _MineChargeVipPageState(); +} + +// 多入口可叠栈,用 per-实例唯一 tag 隔离 controller,避免轮播/选中卡/onReady 串味 +class _MineChargeVipPageState extends State + with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: MineChargeVipLogic( + vipID: widget.vipID, sourcePage: widget.sourcePage), + builder: (logic) => Consumer( + builder: (_, mgr, __) { + // ACTIVE+B → 改版 UI;A / DISABLED / 无实验 → 旧版 UI + final useNew = mgr.isNewVipUi; + return Scaffold( + extendBodyBehindAppBar: true, + backgroundColor: useNew ? const Color(0xff0F0F0F) : null, + appBar: AppBar( + elevation: 0, + backgroundColor: Colors.transparent, + iconTheme: IconThemeData(color: Colors.white), + title: Text('会员中心', style: TextStyle(color: Colors.white)), + actions: [ + InkWell( + enableFeedback: false, + onTap: () => Get.to(RecordsPage(RecordType.recharge)), + child: Text('充值记录', + style: TextStyle( + color: Colors.white.withValues(alpha: .45), + fontSize: 12)), + ), + 16.sizeBoxW, + ], + ), + body: useNew + ? Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + child: _buildPageBackground(mgr), + ), + _buildBody(logic, classic: false), + ], + ) + : _buildBody(logic, classic: true), + ); + }, + ), + ); + } + + /// B 组页顶背景:优先 uiConfig.backgroundImage,空则本地默认图 + Widget _buildPageBackground(VipProductManager mgr) { + final url = mgr.vipBgImage; + if (url != null) { + return NetworkImageLoader( + imageUrl: url, + width: double.infinity, + fit: BoxFit.fitWidth, + ); + } + return Image.asset( + 'mine_vip_bg.webp'.mineImgPath, + width: double.infinity, + fit: BoxFit.fitWidth, + alignment: Alignment.topCenter, + ); + } + + Widget _buildBody(MineChargeVipLogic logic, {required bool classic}) { + if (vipProductManager.isLoading) return LoadingCenterWidget(); + if (vipProductManager.vipCards.isEmpty) { + return CErrorWidget(retryOnTap: () => logic.loadCards(force: true)); + } + return Stack( + children: [ + Column( + children: [ + (kToolbarHeight + screen.paddingTop).sizeBoxH, + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + 12.sizeBoxH, + UserChargeView(classic: classic), + 26.sizeBoxH, + _buildCardSlider(logic), + if (vipProductManager.presaleGroup != null) ...[ + 20.sizeBoxH, + PreSaleVipEntry().paddingSymmetric(horizontal: 16), + ], + // B:卡皮与核心权益标题间距略收;A 保持原 14 + (classic ? 14 : 4).sizeBoxH, + _buildPrivileges(classic: classic), + 100.sizeBoxH, + ], + ), + ), + ), + ], + ), + Positioned( + bottom: 0, left: 0, right: 0, child: _buildPay(classic: classic)), + ], + ); + } + + //会员卡轮播 + Widget _buildCardSlider(MineChargeVipLogic logic) { + return CarouselSlider.builder( + carouselController: logic.pageCtr, + itemCount: vipProductManager.vipCards.length + 2, + //选中态跟着 kSelection 单卡刷新,避免滑一次卡把整个轮播和页面重建一遍 + itemBuilder: (_, index, __) => GetBuilder( + tag: uniqueTag, + id: MineChargeVipLogic.kSelection, + builder: (l) => l.instanceChildItem(index), + ), + options: CarouselOptions( + height: logic.cardHeight, + viewportFraction: logic.itemRatio, + enableInfiniteScroll: false, + enlargeCenterPage: false, + padEnds: false, + // 选中卡底部 VIP/Glow Gold 不被 PageView 裁切 + clipBehavior: Clip.none, + onPageChanged: (index, __) => logic.onPageChanged(index), + ), + ); + } + + //权益区:跟随选中卡,订阅 kSelection 局部刷新;newPrivilege 只遍历一次按 isCore 分组 + Widget _buildPrivileges({required bool classic}) { + return GetBuilder( + tag: uniqueTag, + id: MineChargeVipLogic.kSelection, + builder: (logic) { + final privileges = logic.currentProductModel?.newPrivilege ?? []; + final coreList = []; + final moreList = []; + for (final p in privileges) { + (p.isCore == true ? coreList : moreList).add(p); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + classic + ? _buildClassicCorePrivileges(coreList) + : _buildCorePrivileges(coreList), + classic + ? _buildClassicMorePrivileges(moreList) + : _buildMorePrivileges(moreList), + ], + ); + }, + ); + } + + //核心权益:横向滚动卡片(左图标 + 右文案)—— B 改版 + Widget _buildCorePrivileges(List list) { + if (list.isEmpty) return const SizedBox.shrink(); + return Column( + children: [ + const VipCoreSectionTitleImage(), + 16.sizeBoxH, + SizedBox( + height: 92, + child: ListView.separated( + clipBehavior: Clip.none, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 2, 16, 26), + itemCount: list.length, + separatorBuilder: (_, __) => 8.sizeBoxW, + itemBuilder: (_, index) => VipCorePrivilegeCard(list[index]), + ), + ), + 16.sizeBoxH, + ], + ); + } + + //核心权益:4 列金边方卡 —— A / DISABLED 旧版 + Widget _buildClassicCorePrivileges(List list) { + if (list.isEmpty) return const SizedBox.shrink(); + return Column( + children: [ + const VipSectionTitle("我的核心权益", classic: true), + 16.sizeBoxH, + GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 14), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 1, + ), + itemCount: list.length, + itemBuilder: (_, index) => FittedBox( + fit: BoxFit.contain, + child: SizedBox( + width: 72, + height: 72, + child: VipCorePrivilegeCard(list[index], classic: true)), + ), + ), + 16.sizeBoxH, + ], + ); + } + + //更多权益:深色圆角容器 + 四列网格 + 展开/收起 —— B 改版 + Widget _buildMorePrivileges(List list) { + if (list.isEmpty) return const SizedBox.shrink(); + return _VipMorePrivilegesPanel( + list: list, + title: const VipMoreSectionTitleImage(), + ); + } + + //会员特权:直接四列网格 —— A / DISABLED 旧版 + Widget _buildClassicMorePrivileges(List list) { + if (list.isEmpty) return const SizedBox.shrink(); + return Column( + children: [ + const VipSectionTitle("我的会员特权", classic: true), + 12.sizeBoxH, + GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 18), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 12, + crossAxisSpacing: 18, + childAspectRatio: 63 / 100, + ), + itemCount: list.length, + itemBuilder: (_, index) => + VipProductPrivilegeItem(list[index], classic: true), + ), + ], + ); + } + + //底部支付区域:只有按钮跟选中卡走,渐变底和客服文案是静态的 + Widget _buildPay({required bool classic}) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.black.withValues(alpha: 0), Colors.black], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 12.sizeBoxH, + GetBuilder( + tag: uniqueTag, + id: MineChargeVipLogic.kSelection, + builder: (logic) => VipPayButton(logic, classic: classic), + ), + 12.sizeBoxH, + EasyRichText( + '支付问题反馈,点击联系 在线客服', + patternList: [ + EasyRichTextPattern( + targetString: '在线客服', + style: TextStyle(color: Color(0xFFFFD460), fontSize: 12), + recognizer: TapGestureRecognizer() + ..onTap = () => pushToCustomService(), + ) + ], + defaultStyle: TextStyle(color: Color(0xffDCDCDC), fontSize: 12), + ), + // 垫上虚拟导航栏高度,避免底部内容被遮挡(edge-to-edge) + (12 + screen.paddingBottom).sizeBoxH, + ], + ), + ); + } +} + +/// 更多权益:深色圆角面板 + 四列网格,默认展开,可收起为 2 行 +class _VipMorePrivilegesPanel extends StatefulWidget { + final List list; + final Widget title; + + const _VipMorePrivilegesPanel({required this.list, required this.title}); + + @override + State<_VipMorePrivilegesPanel> createState() => + _VipMorePrivilegesPanelState(); +} + +class _VipMorePrivilegesPanelState extends State<_VipMorePrivilegesPanel> { + static const _collapsedCount = 8; // 收起时展示 2 行 × 4 + static const _radius = 24.0; + bool _expanded = true; + + /// 描边:上→下 #FFFBE5 30% → 0% → 30%(上下可见,左右中段淡出) + static const _borderGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x4DFFFBE5), Color(0x00FFFBE5), Color(0x4DFFFBE5)], + ); + + @override + Widget build(BuildContext context) { + final canToggle = widget.list.length > _collapsedCount; + final showCount = + (!_expanded && canToggle) ? _collapsedCount : widget.list.length; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: CustomPaint( + painter: _FigmaGradientBorderPainter( + gradient: _borderGradient, + strokeWidth: 1, + radius: _radius, + inner: true, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(_radius), + child: ColoredBox( + color: const Color(0x0DFFFFFF), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 14, 12, 10), + child: Column( + children: [ + widget.title, + 14.sizeBoxH, + GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 12, + crossAxisSpacing: 10, + childAspectRatio: 72 / 110, + ), + itemCount: showCount, + itemBuilder: (_, index) => + VipProductPrivilegeItem(widget.list[index]), + ), + if (canToggle) ...[ + 8.sizeBoxH, + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _expanded ? '收起特权' : '展开特权', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.55), + fontSize: 12, + ), + ), + Icon( + _expanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: 16, + color: Colors.white.withValues(alpha: 0.55), + ), + ], + ), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Figma 线性渐变描边:shader 按节点 bounds 映射(与 Figma stroke fill 一致) +class _FigmaGradientBorderPainter extends CustomPainter { + final Gradient gradient; + final double strokeWidth; + final double radius; + final bool inner; + + _FigmaGradientBorderPainter({ + required this.gradient, + required this.strokeWidth, + required this.radius, + this.inner = true, + }); + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + // Inner:描边中心线向内缩 strokeWidth/2,使整条描边落在边界内侧 + final inset = inner ? strokeWidth / 2 : 0.0; + final rrect = RRect.fromRectAndRadius( + rect.deflate(inset), + Radius.circular(math.max(0, radius - inset)), + ); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..shader = gradient.createShader(rect); + canvas.drawRRect(rrect, paint); + } + + @override + bool shouldRepaint(covariant _FigmaGradientBorderPainter oldDelegate) { + return oldDelegate.gradient != gradient || + oldDelegate.strokeWidth != strokeWidth || + oldDelegate.radius != radius || + oldDelegate.inner != inner; + } +} diff --git a/lib/hj_page/mine/mine_vip/online_pay_page.dart b/lib/hj_page/mine/mine_vip/online_pay_page.dart new file mode 100644 index 0000000..ca5e747 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/online_pay_page.dart @@ -0,0 +1,597 @@ +import 'dart:convert'; + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../assets_tool/app_colors.dart'; +import '../../../assets_tool/images.dart'; +import '../../../config/address.dart'; +import '../../../hj_model/mine/exchange/dc_model.dart'; +import '../../../hj_model/mine/exchange/recharge_type_list_model.dart'; +import '../../../hj_utils/api_service/mine_service.dart'; +import '../../../hj_utils/pay/pay_manager.dart'; +import '../../../hj_utils/widget_util.dart'; +import '../../../routers/jump_router.dart'; +import '../../../tools_base/global_store/store.dart'; +import '../../../tools_base/loading/loading_helper.dart'; +import '../../../tools_base/net/net_manager.dart'; +import '../../main_page/provider/msg_provider.dart'; +import '../../web_page/h5_page.dart'; +import 'pay_order_source.dart'; +import 'pay_success_alert.dart'; +import 'vip_card_analytics.dart'; +import 'vip_product_manager.dart'; +import 'vip_support_model.dart'; + +/// 在线支付弹窗:VIP 会员卡购买(vipProductModel) 或 金币充值(coinRcModel) 二选一, +/// 底部弹出,展示支付方式列表 + 支付按钮;下单渠道分代充(dc)/线上跳链/金币三种。 +class OnlinePayPage extends StatefulWidget { + final VipProductModel? vipProductModel; // 传此值:购买 VIP 会员卡 + final RechargeTypeModel? coinRcModel; // 传此值:充值金币 + /// 下单来源/实验/会话等埋点信息(透传 /mine/topay) + final PayOrderTrackInfo? orderTrack; + + const OnlinePayPage({ + super.key, + this.vipProductModel, + this.coinRcModel, + this.orderTrack, + }); + + @override + State createState() => _OnlinePayPageState(); +} + +class _OnlinePayPageState extends State { + // 当前选中的支付方式下标 + int payIndex = 0; + // 支付请求防重入标记 + bool isPaying = false; + + // 加赠券:isCouponPanel 控制切到选券面板,coupon 为已选券 + bool isCouponPanel = false; + CouponModel? coupon; + + // 支付方式列表。会员卡与金币档位二选一,getter 每次访问都会重建列表,故每帧只取一次 + List get _payTypes => + widget.vipProductModel?.rchgTypeUI ?? + widget.coinRcModel!.rechargeTypeListUI; + + /// 支付按钮文案:预售(尾款/升级/预订) → 会员卡(升级/普通) → 金币充值 + String get _payText { + final vip = widget.vipProductModel; + if (vip == null) return '¥${widget.coinRcModel?.moneyYuan ?? 0}/立即支付'; + if (!vip.isPreSale) + return '¥${vip.discountedPriceUI}/${vip.isUpgrade == true ? '补差价升级' : '立即支付'}'; + // 预售:可付尾款优先,其次升级,最后预订 + final preSale = PreSaleProvider(); + final detail = preSale.preSaleModel?.detailModel; + if (preSale.canPayBalance) return '¥${detail?.balanceAmount ?? 0}支付尾款'; + if (vip.isUpgrade == true) return '¥${(vip.advanceAmount ?? 0) ~/ 10}/立即升级'; + return '¥${detail?.advanceAmount ?? 0}立即预订'; + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + decoration: ShapeDecoration( + color: AppColors.primaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), + ), + ), + ), + // 选中券类型支付方式时切到选券面板(本项目通常无券数据) + child: isCouponPanel + ? ChoseCouponView( + onSelect: (model) => setState(() { + isCouponPanel = false; + coupon = model; + }), + ) + : _payPanel(), + ); + } + + //支付主面板 + Widget _payPanel() { + final types = _payTypes; + final curType = types[payIndex]; // 当前选中的支付方式,与列表同源,build 时取 + return Column(mainAxisSize: MainAxisSize.min, children: [ + const SheetHandleBar(), + 18.sizeBoxH, + const Text( + '选择支付方式', + style: TextStyle( + fontSize: 20, fontWeight: FontWeight.w500, color: Colors.white), + ), + 18.sizeBoxH, + //支付方式列表 + Wrap(children: [ + for (var i = 0; i < types.length; i++) _payItem(types[i], i) + ]), + //支付小贴士 + Padding( + padding: EdgeInsets.only(bottom: 24.h, left: 16, right: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '支付小贴士:', + style: textStyle( + 14, Colors.white.withValues(alpha: .9), FontWeight.w500), + ), + 6.sizeBoxH, + Text( + '''1.因超时支付无法到账,请重新发起。 +2.每天发起支付不能超过5次,连续发起且未支付,账号可能被加入黑名单。''', + style: textStyle( + 12, Colors.white.withValues(alpha: .45), FontWeight.w400), + ), + ], + ), + ), + //支付按钮 + GestureDetector( + onTap: () => _onPay(curType), + child: Container( + alignment: Alignment.center, + height: 44, + decoration: ShapeDecoration( + color: AppColors.actionRed, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(3), + ), + ), + child: Text( + _payText, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 20.sp, + color: Colors.white), + ), + ), + ), + 18.sizeBoxH, + //客服入口 + Center( + child: EasyRichText( + '支付中如有问题,请咨询 在线客服', + defaultStyle: textStyle( + 12, Colors.white.withValues(alpha: .6), FontWeight.w500), + patternList: [ + EasyRichTextPattern( + targetString: '在线客服', + style: textStyle(12, Color(0xffFFD460), FontWeight.w500), + recognizer: TapGestureRecognizer() + ..onTap = () => pushToCustomService(), + ), + ], + ), + ), + ]); + } + + //单个支付方式 + Widget _payItem(RchgType type, int index) { + final icon = type.getPayIcon(); + return InkWell( + enableFeedback: false, + onTap: () => setState(() { + //券类型切到选券面板,其余直接切换选中 + if (type.type == 'coupon') { + isCouponPanel = true; + } else { + payIndex = index; + } + }), + child: Container( + height: 41, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12), + margin: EdgeInsets.only(bottom: 20.h), + child: Row( + children: [ + icon.isEmpty + ? const SizedBox(width: 36, height: 36) + : Image.asset(icon, width: 36), + 13.sizeBoxW, + Expanded( + child: Text( + "${type.typeName}", + style: textStyle( + 16, Colors.white.withValues(alpha: .9), FontWeight.w500), + ), + ), + Image.asset( + payIndex == index + ? 'radio_sel.png'.commonImgPath + : 'mine_withdraw_nor.png'.mineImgPath, + width: 16), + ], + ), + ), + ); + } + + //按选中的支付方式下单:金币余额 / 代充 / 线上跳链 + void _onPay(RchgType type) { + final vip = widget.vipProductModel; + final coinRc = widget.coinRcModel; + if (type.type == "coin") { + _payByCoin(); + } else if (type.isOfficial == true) { + // 正常购买:金币 / vip购买 + if (coinRc != null) { + _payByDc(false, type, coinRc.daichong, coinRc.money); + } else { + _payByDc(true, type, vip?.daichong, (vip?.discountedPrice ?? 0) * 10); + } + } else { + _payByLink(type); + } + } + + ///线上跳链支付:创建支付单后跳外部支付页 + Future _payByLink(RchgType payType) async { + final vip = widget.vipProductModel; // 非空 = 购买会员卡,空 = 购买金币 + final productId = vip != null ? vip.productID! : widget.coinRcModel!.id; + if (isPaying) return; + isPaying = true; + LoadingHelper.showLoading(); + // 合并入口透传;仅实验 ACTIVE 时附带卡皮实验信息(copyWith 无法清 null,故重建) + final base = widget.orderTrack ?? const PayOrderTrackInfo(); + final orderTrack = PayOrderTrackInfo( + sourcePage: base.sourcePage ?? PaySourcePage.unknown, + sourceRef: base.sourceRef, + videoId: base.videoId, + activityId: base.activityId, + sessionId: base.sessionId, + //短剧付费墙的归因字段,这里重建时漏抄会被服务端 8001 拒单 + mediaId: base.mediaId, + contentId: base.contentId, + checkoutContextId: base.checkoutContextId, + experimentId: vipProductManager.isExperimentActive + ? (base.experimentId ?? + (vip == null + ? null + : vip.experimentId ?? vipProductManager.experimentId)) + : null, + experimentVariant: vipProductManager.isExperimentActive + ? (base.experimentVariant ?? + (vip == null ? null : vip.variant ?? vipProductManager.variant)) + : null, + ); + final urlModel = await MineService.chargeGoldCoin( + payType.type, + productId: productId, + isVip: vip != null, + goldExtraID: coupon?.cId, + finalPayStatus: vip?.isPreSale == true + ? PreSaleProvider().preSaleModel?.detailModel?.balancePayment + : null, + orderTrack: orderTrack, + ); + LoadingHelper.dismissLoading(); + if (urlModel != null) { + if (urlModel.mode == "url") { + await launchUrlToWeb(urlModel.payUrl ?? ''); + } else if (urlModel.mode == "sdk") { + showToast("没有找到支付类型为:${payType.type} 的sdk"); + Get.back(); + isPaying = false; + return; + } + + // VIP 已创建支付单:视为产生购买行为,关闭会员卡页不再报无购买关闭 + if (vip != null) VipCardAnalyticsSession.markPurchaseOnActiveSessions(); + Get.back(); + Future.delayed(const Duration(milliseconds: 1500), () { + //支付成功刷新vip到期时间 + Get.dialog( + const Center(child: PaySuccessAlert()), + barrierColor: const Color(0x22000000), + barrierDismissible: true, + ); + }); + } + isPaying = false; + } + + ///金币余额支付(仅会员卡有该方式) + Future _payByCoin() async { + if (isPaying) return; + isPaying = true; + final vip = widget.vipProductModel; + final track = widget.orderTrack; + final experimentActive = vipProductManager.isExperimentActive; + await PayManager().buyVip( + vip?.productType, + vip?.productID, + vip?.productName, + vip?.discountedPrice, + source: 'online_pay', + jumpWalletOnInsufficient: false, // 当前就在充值页,余额不足不跳转 + finalPayStatus: vip?.isPreSale == true + ? PreSaleProvider().preSaleModel?.detailModel?.balancePayment + : null, + // 与 /mine/topay 一致:仅实验 ACTIVE 时回传;sessionId 有则带上 + experimentId: experimentActive + ? (track?.experimentId ?? + vip?.experimentId ?? + vipProductManager.experimentId) + : null, + experimentVariant: experimentActive + ? (track?.experimentVariant ?? + vip?.variant ?? + vipProductManager.variant) + : null, + sessionId: track?.sessionId, + //短剧付费墙开卡:金币余额支付这条也要带归因,否则只有第三方充值那条统计得到 + mediaId: track?.mediaId, + contentId: track?.contentId, + checkoutContextId: track?.checkoutContextId, + onSuccess: (data) async { + VipCardAnalyticsSession.markPurchaseOnActiveSessions(); + showToast("购买成功"); + globalStore.refreshWallet(); + await globalStore.updateUserInfo(); + MineMsgProvider().refreshPayPopup(); //会员状态变了,重拉分层与付费引导开关(买完别再弹引导) + Get.back(); + }, + ); + isPaying = false; + } + + ///代充支付:拼装代充参数后跳 H5 收银台 + Future _payByDc( + bool isVip, RchgType payType, DCModel? dcModel, int? money) async { + // daichong 是所有会员卡/金币档位共用的同一实例,下面的改写只为拼给 H5,必须在副本上做: + // 原地改会把 payInfos 截成单条(支付方式列表变短→下标越界)、ordUrl 被重复拼 host + final dc = dcModel!.clone(); + final payList = dc.traders![0].payInfos!; + PayInfoModel? payInfo; + for (final model in payList) { + if (model.payMethod == payType.payMethod) { + payInfo = model; + break; + } + } + //设置默认值 + if (dc.limit == 0) dc.limit = 500; + final payMoney = money ?? 0; + //超额度走大额通道 + if (payInfo!.payType!.contains(2) && payInfo.payType!.contains(3)) { + payInfo.payType = (payMoney / 100) > dc.limit! ? [3] : [2]; + } + if (payInfo.payType!.length >= 3) { + payInfo.payType = (payMoney / 100) > dc.limit! ? [1, 3] : [1, 2]; + } + dc.traders![0].payInfos = [payInfo]; + + final host = Address.baseHost!; + dc.ordUrl = host + dc.ordUrl!; + dc.traderUrl = host + dc.traderUrl!; + dc.chargeMoney = payMoney ~/ 100; + final channel = payType.channel!; + dc.channel = channel; + final token = await netManager.getToken(); + //bt64 + final data = base64Encode(utf8.encode(json.encode(dc))); + if (isVip) VipCardAnalyticsSession.markPurchaseOnActiveSessions(); + Get.to(H5Page(title: "代理充值", url: "$channel/?data=$data&token=$token"), + opaque: false); + } +} + +/// 加赠券选择面板:拉取用户券列表,选中经 onSelect 回传(传 null 表示返回不选) +class ChoseCouponView extends StatefulWidget { + final Function(CouponModel? coupon) onSelect; + + const ChoseCouponView({super.key, required this.onSelect}); + + @override + State createState() => _ChoseCouponViewState(); +} + +class _ChoseCouponViewState extends State { + bool isLoading = true; + final _dataSource = []; + int page = 1; + + // refreshCtr 由 CustomRefreshView 创建并 dispose,本类只持引用,绝不能再 dispose + RefreshController? refreshCtr; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _fetch()); + } + + Future _fetch({bool isRefresh = true}) async { + if (isRefresh) page = 1; + final res = await MineService.fetchUserCoupons(1, page: page); + isLoading = false; + + if (isRefresh) { + refreshCtr?.refreshCompleted(); + _dataSource.clear(); + } + _dataSource.length < 20 + ? refreshCtr?.loadNoData() + : refreshCtr?.loadComplete(); + _dataSource.addAll(res); + page += 1; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxHeight: 400), + child: Column( + children: [ + //顶部拖动条 + Container( + height: 41, + alignment: Alignment.center, + child: const SheetHandleBar(color: Colors.black12), + ), + //返回 + 标题 + Stack( + children: [ + GestureDetector( + onTap: () => widget.onSelect(null), + child: Image.asset( + 'common_back.png'.commonImgPath, + width: 18, + height: 18, + color: Colors.black.withValues(alpha: .9), + ), + ), + const Center( + child: Text( + '选择加赠券', + style: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.w600), + ), + ) + ], + ), + 18.sizeBoxH, + Expanded( + child: pullYsRefresh( + onRefresh: (_) => _fetch(), + onLoading: (_) => _fetch(isRefresh: false), + child: () { + if (isLoading) return const LoadingCenterWidget(); + if (_dataSource.isEmpty) return const CErrorWidget(); + return ListView.separated( + separatorBuilder: (_, __) => 12.sizeBoxH, + itemCount: _dataSource.length, + itemBuilder: (_, index) => _couponItem(_dataSource[index]), + ); + }(), + onInit: (ctr) => refreshCtr = ctr), + ) + ], + ), + ); + } + + Widget _couponItem(CouponModel model) { + return Container( + height: 72, + padding: const EdgeInsets.only(left: 24, right: 11), + alignment: Alignment.centerLeft, + width: double.infinity, + decoration: BoxDecoration( + color: Color(0xffff891c).withValues(alpha: .4), + borderRadius: BorderRadius.circular(4)), + child: Row( + children: [ + //券面额 + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Image.asset( + 'coin_icon.webp'.mineImgPath, + width: 16, + height: 16, + ), + 4.sizeBoxW, + Text( + '${model.price ?? 0}', + style: TextStyle( + color: Colors.black, + fontSize: 24, + fontWeight: FontWeight.w500, + height: 33 / 24), + ) + ], + ), + Text( + '充值加送金币', + style: TextStyle( + color: Colors.black.withValues(alpha: .6), fontSize: 12), + ) + ], + ), + 23.sizeBoxW, + SizedBox( + width: 1, + height: 45, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient(colors: [ + Colors.black.withValues(alpha: 0), + Colors.black.withValues(alpha: .5), + Colors.black.withValues(alpha: 0) + ], begin: Alignment.topCenter, end: Alignment.bottomCenter), + ), + ), + ), + 12.sizeBoxW, + //券说明 + 有效期 + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '金币加购券', + style: TextStyle( + color: Colors.black, + fontSize: 12, + ), + ), + 4.sizeBoxH, + Text( + '有效期:${model.expireTime?.utcToYMDHM()}', + style: TextStyle( + color: Colors.black.withValues(alpha: .6), + fontSize: 8, + ), + ) + ], + ), + ), + 11.sizeBoxW, + GestureDetector( + onTap: () => widget.onSelect(model), + child: Container( + decoration: BoxDecoration( + color: Color(0xffF68216), + borderRadius: BorderRadius.circular(22)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: Text( + '立即使用', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w500), + ), + ), + ) + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_vip/pay_order_source.dart b/lib/hj_page/mine/mine_vip/pay_order_source.dart new file mode 100644 index 0000000..c1fce51 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/pay_order_source.dart @@ -0,0 +1,86 @@ +/// 创建订单(/mine/topay)的来源页,[value] 与后端约定一致 +enum PaySourcePage { + homeUserSegment('HOME_USER_SEGMENT'), //用户分层首页弹窗 + homeFloatWindow('HOME_FLOAT_WINDOW'), //首页右下角浮窗(分层浮窗 + 活动浮窗内链) + videoBottomBanner('VIDEO_BOTTOM_BANNER'), //视频播放页下方 banner + videoBottomSheet('VIDEO_BOTTOM_SHEET'), //视频播放页底部弹窗(含播放页金币解锁) + vipCenter('VIP_CENTER'), //会员中心 + h5Activity('H5_ACTIVITY'), //H5 活动 + dramaPaywall('DRAMA_PAYWALL'), //短剧付费墙(金币充值 / 开短剧卡) + unknown('UNKNOWN'); //入口没指定 + + final String value; + const PaySourcePage(this.value); +} + +/// 下单(/mine/topay)透传的埋点信息:来源 / 实验 / 会话等,均为非必填 +class PayOrderTrackInfo { + /// 来源页 + final PaySourcePage? sourcePage; + + /// 来源关联(如 videoId 文案标识) + final String? sourceRef; + + /// 关联视频 ID + final String? videoId; + + /// 活动 ID + final String? activityId; + + /// 实验 ID(VIP 卡皮 A/B) + final String? experimentId; + + /// 实验分组(对应接口 experimentVariant) + final String? experimentVariant; + + /// 会话 ID(与 VIP 卡片统计 session 对齐) + final String? sessionId; + + /// 短剧:当前剧 ID + final String? mediaId; + + /// 短剧:当前分集 ID + final String? contentId; + + /// 短剧:本次付费墙上下文,服务端据此把充值/开卡订单归因到这一集 + final String? checkoutContextId; + + const PayOrderTrackInfo({ + this.sourcePage, + this.sourceRef, + this.videoId, + this.activityId, + this.experimentId, + this.experimentVariant, + this.sessionId, + this.mediaId, + this.contentId, + this.checkoutContextId, + }); + + PayOrderTrackInfo copyWith({ + PaySourcePage? sourcePage, + String? sourceRef, + String? videoId, + String? activityId, + String? experimentId, + String? experimentVariant, + String? sessionId, + String? mediaId, + String? contentId, + String? checkoutContextId, + }) { + return PayOrderTrackInfo( + sourcePage: sourcePage ?? this.sourcePage, + sourceRef: sourceRef ?? this.sourceRef, + videoId: videoId ?? this.videoId, + activityId: activityId ?? this.activityId, + experimentId: experimentId ?? this.experimentId, + experimentVariant: experimentVariant ?? this.experimentVariant, + sessionId: sessionId ?? this.sessionId, + mediaId: mediaId ?? this.mediaId, + contentId: contentId ?? this.contentId, + checkoutContextId: checkoutContextId ?? this.checkoutContextId, + ); + } +} diff --git a/lib/hj_page/mine/mine_vip/pay_success_alert.dart b/lib/hj_page/mine/mine_vip/pay_success_alert.dart new file mode 100644 index 0000000..8d07aea --- /dev/null +++ b/lib/hj_page/mine/mine_vip/pay_success_alert.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/common_dialog.dart'; + +import '../../../routers/jump_router.dart'; +import '../../../tools_base/global_store/store.dart'; + +class PaySuccessAlert extends StatelessWidget { + const PaySuccessAlert({super.key}); + + @override + Widget build(BuildContext context) { + return CommonDialog( + child: _buildContent(), + ); + } + + _buildContent() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "支付确认", + style: TextStyle( + fontSize: 20, + color: Colors.white.withValues(alpha: .9), + fontWeight: FontWeight.w500, + ), + ), + 12.sizeBoxH, + 0.5.line, + 12.sizeBoxH, + Text( + '''1.订单支付后,一般会在1-10分钟内到账,如超时未到账,请联系在线客服为您处理。 +2.受特殊行业限制,如支付失败可尝试重新发起订单,系统将会随机切换备用的支付通道。 +3.本APP有稳定的广告收益,产品稳定安全,请放心充值使用,如遇报毒提示忽略即可。''', + style: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 14, + height: 1.8, + ), + ), + 12.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () => pushToCustomService(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '支付遇到问题', + style: TextStyle(color: Color(0xff999999), fontSize: 14), + ), + 2.sizeBoxW, + Icon( + Icons.arrow_forward_ios_outlined, + size: 14, + color: Colors.white, + ) + ], + )), + 24.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () { + globalStore.updateUserInfo(); + Get.back(); + }, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(3)), + color: AppColors.actionRed, + ), + child: Text( + "支付成功", + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/mine/mine_vip/vip_card_analytics.dart b/lib/hj_page/mine/mine_vip/vip_card_analytics.dart new file mode 100644 index 0000000..4d297ed --- /dev/null +++ b/lib/hj_page/mine/mine_vip/vip_card_analytics.dart @@ -0,0 +1,149 @@ +import 'package:flutter/scheduler.dart'; +import 'package:hgdj/hj_model/mine/vip_card_analytics_event.dart'; +import 'package:hgdj/hj_page/mine/mine_vip/vip_product_manager.dart'; +import 'package:hgdj/hj_page/mine/mine_vip/vip_support_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:uuid/uuid.dart'; + +/// VIP 卡片统计:一次进入会员卡页/购买弹窗对应一个 session,关闭时按是否下单上报。 +/// 曝光在 UI 实际展示后(post-frame)上报。 +class VipCardAnalyticsSession { + VipCardAnalyticsSession() { + // 进页时快照实验上下文:关闭时 manager 的原始数据可能已被重拉/失效,不能再读 live manager + _experimentActive = vipProductManager.isExperimentActive; + _experimentId = vipProductManager.experimentId; + _variant = vipProductManager.variant; + _active.add(this); + } + + static final _uuid = Uuid(); + static final Set _active = {}; + + /// 当前访问会话 ID(同一次进入会员卡 UI 内事件共用) + final String sessionId = _uuid.v4(); + + /// 进页瞬间的实验快照(关闭上报必须用这份,避免中途 force 重拉把 live 态冲掉) + late final bool _experimentActive; + late final String? _experimentId; + late final String? _variant; + + bool _pageViewReported = false; + bool _purchased = false; + bool _closed = false; + String? _lastImpressedProductId; + + /// 同一帧内 jumpToPage + onPageChanged 去重 + String? _pendingImpressionProductId; + + static String _nowUtc() => + DateTimeUtil.format2utc(DateTime.now().toUtc()) ?? ''; + + /// DISABLED / 无实验 ID:不上报(空 experimentId 会参数错误) + bool get _canReport { + if (!_experimentActive) return false; + final id = _experimentId; + return id != null && id.isNotEmpty; + } + + VipCardAnalyticsEvent _build({ + required String eventName, + String? productId, + int? price, + }) { + return VipCardAnalyticsEvent( + eventId: _uuid.v4(), + eventName: eventName, + sessionId: sessionId, + occurredAt: _nowUtc(), + experimentId: _experimentId, + variant: _variant, + productId: productId, + price: price, + ); + } + + Future _send(List events) async { + if (!_canReport || events.isEmpty) return; + try { + await MineService.reportAnalyticsEvents(events); + } catch (e) { + debugLog('VipCardAnalytics', e); + } + } + + /// 卡皮页展示:UI 就绪后只报一次 + void reportPageViewAfterPaint() { + if (_pageViewReported || _closed || !_canReport) return; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (_pageViewReported || _closed || !_canReport) return; + _pageViewReported = true; + _send([_build(eventName: VipCardAnalyticsEventName.pageView)]); + }); + } + + /// 套餐曝光:选中 / 默认选中各算一次;同 product 连续重复不重复报 + void reportProductImpression(VipProductModel? product, + {bool afterPaint = false}) { + final productId = product?.productID; + if (productId == null || productId.isEmpty || _closed || !_canReport) + return; + if (_lastImpressedProductId == productId) return; + + void doReport() { + if (_closed || !_canReport) return; + if (_lastImpressedProductId == productId) return; + _lastImpressedProductId = productId; + _pendingImpressionProductId = null; + _send([ + _build( + eventName: VipCardAnalyticsEventName.productImpression, + productId: productId, + price: product?.discountedPrice, + ), + ]); + } + + if (afterPaint) { + _pendingImpressionProductId = productId; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (_pendingImpressionProductId != productId) return; + doReport(); + }); + } else { + doReport(); + } + } + + /// 下单成功:本访问内关闭不再报「无购买关闭」 + void markPurchased() => _purchased = true; + + /// 活跃会话均标记已购买(支付页成功回调用) + static void markPurchaseOnActiveSessions() { + for (final s in _active) { + s.markPurchased(); + } + } + + /// 无购买关闭 + void reportCloseWithoutPurchaseIfNeeded() { + if (_closed) return; + _closed = true; + _active.remove(this); + if (_purchased) { + debugLog( + 'VipCardAnalytics', 'skip CLOSE_WITHOUT_PURCHASE: already purchased'); + return; + } + if (!_canReport) { + debugLog( + 'VipCardAnalytics', + 'skip CLOSE_WITHOUT_PURCHASE: canReport=false active=$_experimentActive id=$_experimentId', + ); + return; + } + // 关闭瞬间触发上报;不 await,避免卡住 onClose,但请求走全局 http 不会随页面 dispose 取消 + _send([_build(eventName: VipCardAnalyticsEventName.closeWithoutPurchase)]); + } +} diff --git a/lib/hj_page/mine/mine_vip/vip_card_item.dart b/lib/hj_page/mine/mine_vip/vip_card_item.dart new file mode 100644 index 0000000..6d6cd05 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/vip_card_item.dart @@ -0,0 +1,883 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../main_page/provider/msg_provider.dart'; +import 'vip_product_manager.dart'; +import 'vip_support_model.dart'; +import 'vip_ui_kit.dart'; + +class VipCardItem extends StatefulWidget { + final Function()? callback; + final bool isSelect; + final VipProductModel model; + + const VipCardItem(this.model, + {super.key, this.callback, this.isSelect = false}); + + @override + State createState() { + return _VipCardItemState(); + } +} + +class _VipCardItemState extends State { + /// 分层倒计时停表后(如已是会员),新人卡仍需本地秒级刷新 + final ValueNotifier _localTick = ValueNotifier(0); + Timer? _localTimer; + + /// 仅按套餐 badgeType 决定角标,不看名称 / sort / 默认选中 / actionDesc + String get _badgeType => (widget.model.badgeType ?? '').trim().toUpperCase(); + + String? get _badgeLabel { + switch (_badgeType) { + case 'MOST_POPULAR': + case 'NEW_USER_OFFER': + final text = widget.model.badgeText?.trim() ?? ''; + if (text.isNotEmpty) return text; + // NEW_USER_OFFER 无文案时兜底 + return _badgeType == 'NEW_USER_OFFER' ? '新人特惠' : null; + default: + // 角标只认 badgeType / badgeText,不回退 actionDesc / desc + return null; + } + } + + bool get _isMostPopularBadge => _badgeType == 'MOST_POPULAR'; + + /// 仅 productType==5(新人卡)展示倒计时 + bool get _isNewerOfferCard => widget.model.productType == 5; + + /// 当前应展示的倒计时:仅新人卡;优先分层 lastDiscountTime;会员态无分层时用卡 showCountdownTime(小时) 滚动 + ({String hour, String min, String sec})? get _countdownParts { + if (!_isNewerOfferCard) return null; + final layered = MineMsgProvider().countdownConfig; + if (layered != null) { + return ( + hour: layered.discountHour, + min: layered.discountMin, + sec: layered.discountSec + ); + } + final hours = widget.model.showCountdownTime ?? 0; + if (hours <= 0) return null; + final period = hours * 3600; + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + var remain = period - (nowSec % period); + if (remain <= 0) remain = period; + final h = (remain ~/ 3600).toString().padLeft(2, '0'); + final m = ((remain ~/ 60) % 60).toString().padLeft(2, '0'); + final s = (remain % 60).toString().padLeft(2, '0'); + return (hour: h, min: m, sec: s); + } + + bool get _needLocalTick => + _isNewerOfferCard && + MineMsgProvider().countdownConfig == null && + (widget.model.showCountdownTime ?? 0) > 0; + + @override + void initState() { + super.initState(); + _syncLocalTimer(); + } + + @override + void didUpdateWidget(covariant VipCardItem oldWidget) { + super.didUpdateWidget(oldWidget); + _syncLocalTimer(); + } + + @override + void dispose() { + _localTimer?.cancel(); + _localTick.dispose(); + super.dispose(); + } + + void _syncLocalTimer() { + if (_needLocalTick) { + _localTimer ??= Timer.periodic(const Duration(seconds: 1), (_) { + _localTick.value++; + }); + } else { + _localTimer?.cancel(); + _localTimer = null; + } + } + + String get _title => (widget.model.newName?.trim().isNotEmpty == true) + ? widget.model.newName!.trim() + : (widget.model.productName ?? ''); + + @override + Widget build(BuildContext context) { + _syncLocalTimer(); + // variant=B:组合卡 UI;A / DISABLED:接口卡图 + if (vipProductManager.isNewVipUi) { + return _buildVariantBCard(); + } + return _buildClassicImageCard(); + } + + /// 旧版:选中/未选中靠接口下发卡面图 + 角标/新人倒计时叠加 + Widget _buildClassicImageCard() { + final badgeLabel = _badgeLabel; + return Material( + color: Colors.transparent, + child: InkWell( + enableFeedback: false, + onTap: widget.callback, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Stack( + children: [ + Positioned( + left: 0, + top: 9, + bottom: 10, + right: 0, + child: NetworkImageLoader( + imageUrl: widget.isSelect + ? (widget.model.realSelectVipImage().isEmpty + ? widget.model.realNormalVipImage() + : widget.model.realSelectVipImage()) + : widget.model.realNormalVipImage(), + fit: BoxFit.fill, + ), + ), + if (badgeLabel != null) + Positioned( + right: 0, + top: 0, + child: _buildBadge(badgeLabel), + ), + if (_isNewerOfferCard) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + alignment: Alignment.bottomCenter, + child: _buildNewerTimer(), + ), + ), + ], + ), + ), + ), + ); + } + + /// variant=B:148×157 组合卡;选中金渐变+#B2FFF6DC 边;未选中 #211C12+#14FFFFFF 边 + Widget _buildVariantBCard() { + final selected = widget.isSelect; + final badgeLabel = _badgeLabel; + const cardW = 148.0; + const cardH = 157.0; + const badgeTop = 8.0; + // VIP/Glow Gold:Y10+Blur28,底部预留绘制空间,避免被轮播裁切 + const glowPadBottom = 24.0; + return Material( + color: Colors.transparent, + child: InkWell( + enableFeedback: false, + onTap: widget.callback, + child: SizedBox( + width: cardW, + height: cardH + badgeTop + glowPadBottom, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + right: 0, + top: badgeTop, + height: cardH, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + boxShadow: selected + ? const [ + // VIP/Glow Gold:Y10 / Blur28 / Spread-6 / #DEAB54 35% + BoxShadow( + color: Color(0x59DEAB54), + offset: Offset(0, 10), + blurRadius: 28, + spreadRadius: -6, + ), + ] + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Stack( + fit: StackFit.expand, + children: [ + // 底色 / 渐变 + DecoratedBox( + decoration: BoxDecoration( + color: selected ? null : const Color(0xff211C12), + gradient: selected + ? const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xffFBF0CD), + Color(0xffE5B75D) + ], + ) + : null, + ), + ), + // 内容(含底栏 desc) + Column( + children: [ + Expanded(child: _buildBCardBody(selected)), + _buildBCardFooter(selected), + ], + ), + // 边框置顶,避免被底栏遮住;新人卡选中不描边 + Positioned.fill( + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: (selected && _isNewerOfferCard) + ? null + : Border.all( + color: selected + ? const Color(0xB2FFF6DC) + : const Color(0x14FFFFFF), + width: 1, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + if (badgeLabel != null) + Positioned( + right: 0, + top: 0, + child: _buildBadge(badgeLabel), + ), + ], + ), + ), + ), + ); + } + + Widget _buildBCardBody(bool selected) { + final titleColor = selected ? const Color(0xff3B2B0E) : Color(0xffF6EEDC); + final priceColor = + selected ? const Color(0xff3B2B0E) : const Color(0xffF6EEDC); + final originColor = + selected ? const Color(0xff6C644F) : const Color(0xff9A927C); + final showOrigin = widget.model.originalPriceUI > 0 && + widget.model.originalPriceUI != widget.model.discountedPriceUI; + + return Padding( + padding: const EdgeInsets.fromLTRB(10, 22, 10, 8), + child: Column( + // mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: titleColor, + fontSize: 15, + fontWeight: FontWeight.w500, + height: 1.2, + ), + ), + 10.sizeBoxH, + EasyRichText( + '¥${widget.model.discountedPriceUI}', + defaultStyle: TextStyle( + color: priceColor, + fontSize: 40, + fontWeight: FontWeight.w700, + height: 1, + ), + patternList: [ + EasyRichTextPattern( + targetString: '¥', + matchWordBoundaries: false, + matchOption: 'first', + style: TextStyle( + color: priceColor, + fontSize: 17, + fontWeight: FontWeight.w800, + height: 1, + ), + ), + ], + ), + if (showOrigin) ...[ + 4.sizeBoxH, + Text( + '¥${widget.model.originalPriceUI}', + style: TextStyle( + color: originColor, + fontSize: 13, + decoration: TextDecoration.lineThrough, + decorationColor: originColor, + height: 1.2, + ), + ), + ] else ...[ + // 无原价时占位短横,对齐设计稿 + 10.sizeBoxH, + Text( + '-', + style: TextStyle(color: originColor, fontSize: 13, height: 1.2), + ), + ], + ], + ), + ); + } + + Widget _buildBCardFooter(bool selected) { + if (!_isNewerOfferCard) return _buildBDurationFooter(selected); + + return ValueListenableBuilder( + valueListenable: _needLocalTick ? _localTick : MineMsgProvider().tick, + builder: (_, __, ___) { + final cd = _countdownParts; + if (cd != null) { + if (selected) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 7), + alignment: Alignment.center, + color: const Color(0xffC14A38), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _timerBox(cd.hour), + _timerColon(), + _timerBox(cd.min), + _timerColon(), + _timerBox(cd.sec), + 4.sizeBoxW, + const Text( + '结束', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600), + ), + ], + ), + ); + } + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8), + alignment: Alignment.center, + color: const Color(0xff16140F), + child: Text( + '${cd.hour}:${cd.min}:${cd.sec}后失效', + style: const TextStyle( + color: Color(0xff8A8A8A), + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ); + } + return _buildBDurationFooter(selected); + }, + ); + } + + /// 无倒计时时的底栏:取套餐 desc + Widget _buildBDurationFooter(bool selected) { + final text = widget.model.desc?.trim() ?? ''; + if (text.isEmpty) return const SizedBox.shrink(); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + alignment: Alignment.center, + color: selected ? const Color(0xff2B2314) : const Color(0xff1A150C), + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: selected ? const Color(0xffFFFFFF) : const Color(0xff9A927C), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + Widget _buildBadge(String label) { + final isMostPopular = _isMostPopularBadge; + final useB = vipProductManager.isNewVipUi; + // B 未选中:统一深底金边/金字;B 选中 / A:优先 uiConfig.badgeStyles + final useApiStyle = useB ? widget.isSelect : true; + Color? apiBg; + Color? apiFg; + if (useApiStyle) { + final style = vipProductManager.badgeStyleFor(_badgeType); + apiBg = _parseHexColor(style?.backgroundColor); + apiFg = _parseHexColor(style?.textColor); + } + final Color bg; + final Color fg; + final Border? border; + if (useB && !widget.isSelect) { + bg = const Color(0xCC1A1A1A); + fg = const Color(0xFFE8C078); + border = Border.all(color: const Color(0xFFE8C078), width: 0.8); + } else { + bg = apiBg ?? + (isMostPopular ? const Color(0xCC1A1A1A) : const Color(0xffE1351F)); + fg = apiFg ?? + (isMostPopular ? const Color(0xFFE8C078) : const Color(0xE5FFFFFF)); + border = (apiBg == null && isMostPopular) + ? Border.all(color: const Color(0xFFE8C078), width: 0.8) + : null; + } + return Container( + constraints: const BoxConstraints(maxWidth: 111), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: bg, + border: border, + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: fg, + fontWeight: FontWeight.w500, + fontSize: 10.0, + ), + ), + ); + } + + /// 解析 #RRGGBB / RRGGBB / #AARRGGBB;非法或空返回 null + Color? _parseHexColor(String? raw) { + var hex = (raw ?? '').trim().toUpperCase().replaceAll('#', ''); + if (hex.isEmpty) return null; + if (hex.length == 6) hex = 'FF$hex'; + if (hex.length != 8) return null; + final value = int.tryParse(hex, radix: 16); + return value == null ? null : Color(value); + } + + /// A / DISABLED 旧版新人倒计时:卡底橙色小标签「HH:MM:SS」 + Widget _buildNewerTimer() { + return ValueListenableBuilder( + valueListenable: _needLocalTick ? _localTick : MineMsgProvider().tick, + builder: (_, __, ___) { + final cd = _countdownParts; + if (cd == null) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: const BoxDecoration( + color: Color(0xffF68804), + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + child: Text( + '${cd.hour}:${cd.min}:${cd.sec}', + style: const TextStyle(color: Color(0xff000000), fontSize: 10), + ), + ); + }, + ); + } + + Widget _timerBox(String value) { + return Container( + width: 17, + height: 16, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .1), + borderRadius: BorderRadius.circular(2), + border: Border.all(color: Colors.white, width: 1), + ), + child: Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w500, + height: 1.0), + ), + ); + } + + Widget _timerColon() => const Padding( + padding: EdgeInsets.symmetric(horizontal: 3), + child: Text(':', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600)), + ); +} + +class VipProductPrivilegeItem extends StatelessWidget { + final NewPrivilege? model; + final bool isShowOnePrivilege; + + /// true:旧版白字圆图标样式(会员中心 A / DISABLED) + final bool classic; + + const VipProductPrivilegeItem( + this.model, { + super.key, + this.isShowOnePrivilege = false, + this.classic = false, + }); + + @override + Widget build(BuildContext context) { + if (classic) { + return Column( + children: [ + NetworkImageLoader( + imageUrl: model?.img ?? '', + width: 50, + height: 50, + borderRadius: 25, + ), + 3.sizeBoxH, + Text( + model?.privilegeName ?? '', + maxLines: 1, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: .9)), + ), + if (!isShowOnePrivilege) ...[ + 4.sizeBoxH, + Text( + _descLine(0), + maxLines: 1, + style: TextStyle( + color: Colors.white.withValues(alpha: .45), + fontWeight: FontWeight.w400, + fontSize: 10, + ), + textAlign: TextAlign.center, + ), + 4.sizeBoxH, + Text( + _descLine(1), + maxLines: 2, + style: TextStyle( + color: Colors.white.withValues(alpha: .45), + fontWeight: FontWeight.w400, + fontSize: 9, + ), + textAlign: TextAlign.center, + ), + 2.sizeBoxH, + ], + ], + ); + } + return Column( + children: [ + NetworkImageLoader( + imageUrl: model?.img ?? '', + width: 44, + height: 44, + borderRadius: 22, + ), + 6.sizeBoxH, + VipGradientMask( + child: Text( + model?.privilegeName ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w500, color: Colors.white), + ), + ), + if (!isShowOnePrivilege) ...[ + 4.sizeBoxH, + Text( + _descLine(0), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: .45), + fontWeight: FontWeight.w400, + fontSize: 10, + ), + textAlign: TextAlign.center, + ), + 2.sizeBoxH, + Text( + _descLine(1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: .45), + fontWeight: FontWeight.w400, + fontSize: 9, + ), + textAlign: TextAlign.center, + ), + ], + ], + ); + } + + String _descLine(int index) { + final raw = model?.privilegeDesc ?? ''; + final bySpace = raw.split(' '); + if (bySpace.length > 1) { + return index < bySpace.length ? bySpace[index].trim() : ''; + } + final byLine = raw.split('\n'); + return index < byLine.length ? byLine[index].trim() : ''; + } +} + +/// 核心权益卡片 +/// - classic=false:左图标 + 右标题/描述(会员中心 B / 购买弹窗) +/// - classic=true:72×72 金边方卡(会员中心 A / DISABLED) +class VipCorePrivilegeCard extends StatelessWidget { + final NewPrivilege? model; + final bool classic; + + const VipCorePrivilegeCard(this.model, {super.key, this.classic = false}); + + static const _radius = 16.0; + + static const _fillGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x12FFFFFF), // #FFFFFF 7.06% + Color(0x05FFFFFF), // #FFFFFF 7.06% + ], + ); + + static const _borderGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x29FFFFFF), // #FFFFFF 16.08% + Color(0x0AFFFFFF), // #FFFFFF 3.92% + ], + ); + + @override + Widget build(BuildContext context) { + if (classic) return _buildClassic(); + return Container( + width: 168, + height: 64, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_radius), + boxShadow: const [ + BoxShadow( + color: Color(0x1AFFFFFF), + offset: Offset(0, 1), + blurRadius: 0, + ), + BoxShadow( + color: Color(0x4D000000), + offset: Offset(0, 8), + blurRadius: 20, + spreadRadius: -8, + ), + ], + ), + // 渐变描边用 CustomPaint 画 stroke,避免双层 Container「假边框」透出底层背景 + child: CustomPaint( + painter: _GradientBorderPainter( + gradient: _borderGradient, + strokeWidth: 1, + radius: _radius, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(_radius), + child: DecoratedBox( + decoration: const BoxDecoration(gradient: _fillGradient), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + _buildIcon(), + 10.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + model?.privilegeName ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xffF6EEDC), + fontSize: 14, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + 4.sizeBoxH, + _buildDesc(model?.privilegeDesc ?? ''), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + /// 旧版:正方形金框 + 深棕底图 + 图标/标题/描述 + Widget _buildClassic() { + return Container( + width: 72, + height: 72, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage('core_privilege_bg.webp'.videoPath), + fit: BoxFit.fill, + ), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xffFFF576), width: 0.5), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + NetworkImageLoader( + imageUrl: model?.img ?? '', + width: 22, + height: 22, + borderRadius: 0), + 5.sizeBoxH, + ShaderMask( + shaderCallback: (bounds) => const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xffFF9642), Color(0xffFFE7BD), Color(0xffFCCF36)], + stops: [0.066, 0.49, 0.914], + ).createShader(bounds), + blendMode: BlendMode.srcIn, + child: Text( + model?.privilegeName ?? '', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500), + ), + ), + 3.sizeBoxH, + Text( + model?.privilegeDesc ?? '', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xffB7A463), fontSize: 9), + ), + ], + ), + ); + } + + Widget _buildIcon() { + return Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0x14FFFFFF), + border: Border.all(color: const Color(0x66DCAD55), width: 0.8), + ), + child: NetworkImageLoader( + imageUrl: model?.img ?? '', + width: 26, + height: 26, + borderRadius: 0, + ), + ); + } + + /// 设计稿描述:前半灰、后半金(支持空格分隔;否则前 4 字灰、其余金) + Widget _buildDesc(String raw) { + if (raw.isEmpty) return const SizedBox.shrink(); + return Text( + raw, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(color: Color(0xff9A927C), fontSize: 11, height: 1.2), + ); + } +} + +/// 1px 内渐变描边(stroke),不占用布局厚度,避免双层 Container 透底 +class _GradientBorderPainter extends CustomPainter { + final Gradient gradient; + final double strokeWidth; + final double radius; + + _GradientBorderPainter({ + required this.gradient, + required this.strokeWidth, + required this.radius, + }); + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + final inset = strokeWidth / 2; + final rrect = RRect.fromRectAndRadius( + rect.deflate(inset), + Radius.circular(math.max(0, radius - inset)), + ); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..shader = gradient.createShader(rect); + canvas.drawRRect(rrect, paint); + } + + @override + bool shouldRepaint(covariant _GradientBorderPainter oldDelegate) { + return oldDelegate.gradient != gradient || + oldDelegate.strokeWidth != strokeWidth || + oldDelegate.radius != radius; + } +} diff --git a/lib/hj_page/mine/mine_vip/vip_pay_button.dart b/lib/hj_page/mine/mine_vip/vip_pay_button.dart new file mode 100644 index 0000000..28166f8 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/vip_pay_button.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_alert.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; + +import 'mine_charge_vip_logic.dart'; +import 'vip_support_model.dart'; + +/// 会员支付按钮:会员卡页 MineChargeVipPage 与购买弹窗 BuyVipAlert 共用 +/// [classic] true:旧版红底/升级金橙;false:改版金渐变 CTA +class VipPayButton extends StatelessWidget { + final MineChargeVipLogic logic; + final bool classic; + + const VipPayButton(this.logic, {super.key, this.classic = false}); + + static const _ctaGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xffFBEDC2), + Color(0xffEFCB84), + Color(0xffD9A346), + ], + ); + + @override + Widget build(BuildContext context) { + final product = logic.currentProductModel; + //预售首付或非升级预售 → 预售按钮(透传会员中心 sessionId / 实验字段) + if (product?.isPreSale == true && product?.isUpgrade == false) { + return PreSaleActivityButton(onTap: () async { + await PreSaleProvider() + .startPay(product!, orderTrack: logic.buildOrderTrack()); + logic.update(); + }); + } + if (classic) return _buildClassic(product); + return GestureDetector( + onTap: logic.onInitiatePayAction, + child: Container( + height: 44, + margin: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + boxShadow: const [ + BoxShadow( + color: Color(0x66DEAB54), + offset: Offset(0, 10), + blurRadius: 30, + spreadRadius: -6, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(22), + child: Stack( + alignment: Alignment.center, + children: [ + const DecoratedBox( + decoration: BoxDecoration(gradient: _ctaGradient), + child: SizedBox.expand(), + ), + Positioned( + top: 0, + left: 0, + right: 0, + height: 3, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + const Color(0x8CFFFFFF), + const Color(0x00FFFFFF), + ], + ), + ), + ), + ), + ), + Text( + _payText, + style: const TextStyle( + color: Color(0xff3D2914), + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } + + /// 旧版:升级卡金橙渐变 / 普通卡红底 + Widget _buildClassic(VipProductModel? product) { + final isUpgrade = product?.isUpgrade ?? false; + return GestureDetector( + onTap: logic.onInitiatePayAction, + child: Container( + height: 44, + margin: const EdgeInsets.symmetric(horizontal: 20), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + gradient: isUpgrade + ? const LinearGradient( + colors: [Color(0xFFFFAF50), Color(0xFFE75100)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ) + : null, + color: isUpgrade ? null : AppColors.actionRed, + ), + child: Text( + _payText, + style: const TextStyle( + color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), + ), + ), + ); + } + + String get _payText { + final product = logic.currentProductModel; + if (product == null) return ''; + if (product.isPreSale) { + if (product.isUpgrade ?? false) { + return '${(product.advanceAmount ?? 0) ~/ 10}元/立即升级'; + } + if (PreSaleProvider().canPayBalance) { + return '¥${PreSaleProvider().preSaleModel?.detailModel?.balanceAmount ?? 0}/支付尾款'; + } + return '¥${PreSaleProvider().preSaleModel?.detailModel?.advanceAmount ?? 0}/立即预订'; + } + if (product.isUpgrade ?? false) { + return '¥${product.discountedPriceUI}/补差价升级'; + } + return '¥${product.discountedPriceUI}/立即支付'; + } +} diff --git a/lib/hj_page/mine/mine_vip/vip_product_manager.dart b/lib/hj_page/mine/mine_vip/vip_product_manager.dart new file mode 100644 index 0000000..c5da515 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/vip_product_manager.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_helper.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../../../hj_page/main_page/provider/msg_provider.dart'; +import '../../../hj_page/pre_sale/pre_sale_provider.dart'; +import 'online_pay_page.dart'; +import 'pay_order_source.dart'; +import 'vip_card_analytics.dart'; +import 'vip_support_model.dart'; + +/// 会员卡 / 短剧卡列表统一数据源(全站唯一):请求 / 缓存 / 组装 / 选卡兜底 / 按卡ID直接拉支付。 +/// 会员中心页、购买弹窗、分层横幅等「需要卡列表」的地方用 Consumer 消费。 +/// 页面级状态(当前选中卡 / 轮播控制器 / 支付触发)仍留在各自 Logic,避免多入口串味。 +class VipProductManager with ChangeNotifier { + static final VipProductManager _instance = VipProductManager._(); + factory VipProductManager() => _instance; + VipProductManager._(); + + /// 按卡 id 取卡,会员卡和短剧卡共用。id 空/匹配不上都给 null,让调用方用 `??` 往下一级兜。 + /// 空串必须当「未指定」:分层/实验/短剧默认卡没配时下发的都是空串, + /// 不拦会跟 productID 为空的脏数据撞上 + VipProductModel? _pick(List list, String? id) => + (id ?? '').isEmpty + ? null + : list.firstWhereOrNull((e) => e.productID == id); + + // ===== 会员卡数据 ===== + + /// 原始接口数据(含代充/预售卡组/升级卡等 / 实验态) + VipSupportModel? _raw; + + /// 分层变化等场景标记需重拉;不清 _raw,避免已挂载会员页丢失实验态(B 皮闪回 A) + bool _needsReload = false; + + /// 组装后的会员卡列表(常规卡 + 符合条件的预售卡,按 sort 排序)——全站共享一份 + final vipCards = []; + + /// 加载态 + bool isLoading = true; + + /// 预售卡分组(会员中心页据此判断是否显示预售入口) + VipProductListModel? presaleGroup; + + // ===== A/B 实验 ===== + + /// 实验是否生效(ACTIVE);DISABLED 时不挂实验字段、不上报 analytics、topay 不传实验参数 + bool get isExperimentActive => _raw?.isExperimentActive == true; + + /// 会员中心 UI:仅 ACTIVE 且 variant=B 用改版;A / DISABLED / 无实验时 variant 恒为 null,自然走旧版 + bool get isNewVipUi => (variant ?? '').trim().toUpperCase() == 'B'; + + /// 实验字段(仅 ACTIVE 时有值,下单时原样回传) + String? get experimentId => isExperimentActive ? _raw?.experimentId : null; + + String? get variant => isExperimentActive ? _raw?.variant : null; + + /// 实验指定的默认卡 id。和短剧卡的 [_dramaCardId] 是两码事,别混 + String? get _abCardId => isExperimentActive ? _raw?.defaultProductId : null; + + /// 皮肤配置:ACTIVE 时才消费;DISABLED / 无实验不走接口皮肤 + VipUiConfig? get _uiConfig => isExperimentActive ? _raw?.uiConfig : null; + + /// 会员中心背景图:接口给了就用网络图,否则 null(走本地默认) + String? get vipBgImage { + final url = (_uiConfig?.backgroundImage ?? '').trim(); + return url.isEmpty ? null : url; + } + + /// 角标配色:按套餐 badgeType 匹配皮肤配置 + VipBadgeStyle? badgeStyleFor(String? badgeType) => + _uiConfig?.styleFor(badgeType); + + // ===== 会员卡:请求 / 组装 ===== + + /// 请求会员卡列表。[force] 为 false 且缓存有效时,直接用缓存重组,不发请求。 + Future loadVipCards({bool force = false}) async { + if (_raw != null && !force && !_needsReload) { + _rebuild(); + return; + } + isLoading = true; + notifyListeners(); + final result = await MineService.getVipProduct(); + // 容错:请求失败或返回空数据时,保留上次成功的数据,只复位加载态,不覆盖 _raw/vipCards + if (result == null || (result.list?.isEmpty ?? true)) { + isLoading = false; + notifyListeners(); + return; + } + _raw = result; + _needsReload = false; + _rebuild(); + } + + /// 缓存失效:标记下次 load 强制重拉;保留 _raw/vipCards,避免购卡后已挂载页实验皮闪回 A。 + /// (分层状态变化时调用) + void invalidate() { + _needsReload = true; + } + + /// 切换账号后:清掉上个用户的数据并按新用户权限重新拉取。 + /// 卡列表(尤其预售卡)由后端按用户权限/购买状态返回,切号必须重拉,否则残留上个账号的卡。 + Future reloadForUser() async { + _raw = null; + _needsReload = true; + vipCards.clear(); + await loadVipCards(force: true); + } + + /// 用 _raw 组装 vipCards(会员卡组 + 符合条件的预售卡) + void _rebuild() { + isLoading = false; + presaleGroup = _raw?.list?.firstWhereOrNull((e) => e.position == '预售卡'); + vipCards + ..clear() + ..addAll(_assemble(_raw)); + notifyListeners(); + } + + /// 纯组装:会员卡组 + 符合条件的预售卡。无副作用,供 _rebuild 与 payByVipCard 共用。 + /// 卡序统一按 sort 升序(含卡皮 ACTIVE) + List _assemble(VipSupportModel? raw) { + final cardGroup = raw?.list?.firstWhereOrNull((e) => e.position == '会员卡'); + final presale = raw?.list?.firstWhereOrNull((e) => e.position == '预售卡'); + final list = [...(cardGroup?.list ?? [])]; + // 优先添加预售卡(预售活动中且未付全款) + if ((presale?.list ?? []).isNotEmpty && + PreSaleProvider().advanceStatus?.activityStatus == true && + !PreSaleProvider().isPayAll) { + list.add(presale!.list!.first..isPreSale = true); + } + list.sort((a, b) => (a.sort ?? 0).compareTo(b.sort ?? 0)); + return list; + } + + // ===== 会员卡:选卡 / 支付 ===== + + /// 默认选中卡:外部指定卡ID > 分层卡 > 实验默认卡 > 升级差价卡 > 保留当前 > 第一张。 + /// 选中态归页面 Logic,这里只按 vipCards 计算,[vipID]/[current] 由调用方透传。 + VipProductModel? defaultVipCard({String? vipID, VipProductModel? current}) { + if (vipCards.isEmpty) return null; + return _pick(vipCards, vipID) ?? + _pick(vipCards, MineMsgProvider().payTier?.config?.vipCard) ?? + _pick(vipCards, _abCardId) ?? + (globalStore.meInfo?.isUpgrade == true + ? _cheapestUpgrade(vipCards) + : null) ?? + _pick(vipCards, current?.productID) ?? // 二次刷新时保留当前选中 + vipCards.first; + } + + /// 按卡 ID 直接拉起支付弹窗(跳过会员中心列表页)。无数据时先请求一次。 + /// 选卡:传入 [cardId] > 分层卡 > 实验默认卡 > 升级差价卡 > 第一张 + /// [reportAnalytics] 是否上报 VIP 卡皮事件(PAGE_VIEW / 曝光 / CLOSE);视频底部分层 banner 等入口传 false + Future payByVipCard( + String? cardId, { + PayOrderTrackInfo? orderTrack, + bool reportAnalytics = true, + }) async { + var raw = _raw; + if (raw == null || _needsReload) { + LoadingHelper.showLoading(); + try { + raw = await MineService.getVipProduct(); + } finally { + LoadingHelper.dismissLoading(); //无论成功/异常都关 loading,避免请求抛异常时卡死 + } + // 缓存供后续复用,但不触碰 isLoading/vipCards/notify,避免打扰已挂载的会员卡列表 UI + if (raw != null && (raw.list?.isNotEmpty ?? false)) { + _raw = raw; + _needsReload = false; + } + } + // 用当前预售状态现算列表(不依赖共享 vipCards,也不受其重拉影响) + final list = _assemble(raw); + if (list.isEmpty) { + showToast("未获取到会员卡~"); + return; + } + final card = _pick(list, cardId) ?? + _pick(list, MineMsgProvider().payTier?.config?.vipCard) ?? + _pick(list, _abCardId) ?? + (globalStore.meInfo?.isUpgrade == true + ? _cheapestUpgrade(list) + : null) ?? + list.first; + if (card.rchgTypeUI.isEmpty) { + showToast("未配置支付方式,请联系客服"); + return; + } + // 直拉支付弹窗没有 MineChargeVipLogic:需要卡皮埋点时自建 session,关弹窗报 CLOSE + final analytics = reportAnalytics ? VipCardAnalyticsSession() : null; + final base = orderTrack ?? const PayOrderTrackInfo(); + final track = PayOrderTrackInfo( + sourcePage: base.sourcePage ?? PaySourcePage.unknown, + sourceRef: base.sourceRef, + videoId: base.videoId, + activityId: base.activityId, + sessionId: base.sessionId ?? analytics?.sessionId, + //短剧付费墙的归因字段,重建时漏抄就丢了(同 OnlinePayPage._payByLink) + mediaId: base.mediaId, + contentId: base.contentId, + checkoutContextId: base.checkoutContextId, + // DISABLED:正常下单但不传实验字段 + experimentId: isExperimentActive + ? (base.experimentId ?? card.experimentId ?? experimentId) + : null, + experimentVariant: isExperimentActive + ? (base.experimentVariant ?? card.variant ?? variant) + : null, + ); + analytics?.reportPageViewAfterPaint(); + analytics?.reportProductImpression(card, afterPaint: true); + try { + await Get.bottomSheet( + OnlinePayPage(vipProductModel: card, orderTrack: track), + isScrollControlled: true, + ); + } finally { + analytics?.reportCloseWithoutPurchaseIfNeeded(); + } + } + + /// 多张可升级卡挑一张:待付价低优先,同价预售卡优先;无可升级卡返回 null + VipProductModel? _cheapestUpgrade(List list) { + final upgrades = list.where((e) => e.isUpgrade == true).toList(); + if (upgrades.isEmpty) return null; + upgrades.sort((a, b) { + final cmp = _upgradePrice(a).compareTo(_upgradePrice(b)); + if (cmp != 0) return cmp; + return a.isPreSale ? -1 : 1; //同价预售卡优先 + }); + return upgrades.first; + } + + /// 升级比价用价:预售卡用实际待付(已付首款=尾款 balanceAmount / 未付=定金 advanceAmount),普通卡用 discountedPrice(对齐 mrhs) + int _upgradePrice(VipProductModel card) { + if (card.isPreSale) { + return PreSaleProvider().isPayFirst + ? (card.balanceAmount ?? 0) + : (card.advanceAmount ?? 0); + } + return card.discountedPrice ?? 0; + } +} + +/// 全站唯一实例(对齐 presaleProvider / globalStore 的顶层单例引用) +final vipProductManager = VipProductManager(); diff --git a/lib/hj_page/mine/mine_vip/vip_support_model.dart b/lib/hj_page/mine/mine_vip/vip_support_model.dart new file mode 100644 index 0000000..2ca3ad8 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/vip_support_model.dart @@ -0,0 +1,778 @@ +//会员支持model +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; + +import '../../../hj_model/mine/exchange/dc_model.dart'; + +/// /vip/product 实验状态:ACTIVE 生效,DISABLED 不可用(回原套餐,无实验字段) +abstract class VipExperimentStatus { + static const active = 'ACTIVE'; + static const disabled = 'DISABLED'; +} + +/// uiConfig.badgeStyles 单项:按 badgeType 匹配角标底色/字色 +class VipBadgeStyle { + String? badgeType; + String? backgroundColor; + String? textColor; + + VipBadgeStyle({this.badgeType, this.backgroundColor, this.textColor}); + + VipBadgeStyle.fromJson(Map? json) { + badgeType = json?['badgeType']?.toString(); + backgroundColor = json?['backgroundColor']?.toString(); + textColor = json?['textColor']?.toString(); + } + + Map toJson() => { + 'badgeType': badgeType, + 'backgroundColor': backgroundColor, + 'textColor': textColor, + }; +} + +/// /vip/product.data.uiConfig:皮肤背景 + 角标样式表 +class VipUiConfig { + String? backgroundImage; + List? badgeStyles; + + VipUiConfig({this.backgroundImage, this.badgeStyles}); + + VipUiConfig.fromJson(Map? json) { + backgroundImage = json?['backgroundImage']?.toString(); + final raw = json?['badgeStyles']; + if (raw is List) { + badgeStyles = raw + .whereType() + .map((e) => VipBadgeStyle.fromJson(Map.from(e))) + .toList(); + } + } + + Map toJson() => { + 'backgroundImage': backgroundImage, + 'badgeStyles': badgeStyles?.map((e) => e.toJson()).toList(), + }; + + /// 按 badgeType 查找样式(大小写不敏感) + VipBadgeStyle? styleFor(String? badgeType) { + final key = (badgeType ?? '').trim().toUpperCase(); + if (key.isEmpty) return null; + for (final s in badgeStyles ?? const []) { + if ((s.badgeType ?? '').trim().toUpperCase() == key) return s; + } + return null; + } +} + +class VipSupportModel { + DCModel? daichong; + List? integralList; //积分兑换列表 + bool? isNewUser; + List? list; + List? upgradeableVipCardList; + + /// A/B 实验状态:ACTIVE / DISABLED + String? experimentStatus; + + /// A/B 实验:实验 ID(下单时原样回传;DISABLED 时后端不返回) + String? experimentId; + + /// A/B 实验:分组 A / B(下单时原样回传) + String? variant; + + /// A/B 实验:默认选中卡 ID + String? defaultProductId; + + /// 皮肤 key,如 vip-card-skin-b(视觉皮肤标识,UI 分支仍以 variant 为准) + String? skinKey; + + /// 皮肤 UI 配置(背景图 / 角标样式表) + VipUiConfig? uiConfig; + + /// 实验是否生效(仅 ACTIVE 才挂实验字段 / 上报埋点 / 下单回传实验信息) + bool get isExperimentActive => experimentStatus == VipExperimentStatus.active; + + VipSupportModel({ + this.daichong, + this.integralList, + this.isNewUser, + this.list, + this.upgradeableVipCardList, + this.experimentStatus, + this.experimentId, + this.variant, + this.defaultProductId, + this.skinKey, + this.uiConfig, + }); + + VipSupportModel.fromJson(Map? json) { + daichong = + json?['daichong'] != null ? DCModel.fromJson(json?['daichong']) : null; + if (json?['integralList'] != null) { + integralList = []; + json?['integralList'].forEach((v) { + integralList!.add(IntegralList.fromJson(v)); + }); + } + isNewUser = json?['isNewUser']; + experimentStatus = json?['experimentStatus']?.toString(); + experimentId = json?['experimentId']?.toString(); + variant = json?['variant']?.toString(); + defaultProductId = json?['defaultProductId']?.toString(); + skinKey = json?['skinKey']?.toString(); + uiConfig = json?['uiConfig'] != null + ? VipUiConfig.fromJson(json?['uiConfig']) + : null; + + /// 升级卡 + upgradeableVipCardList = (json?['upgradeableVipCardList'] as List?) + ?.map((e) => VipProductModel.fromJson(e['product'] ?? {})) + .toList(); + if (json?['list'] != null) { + list = []; + json?['list'].forEach((v) { + VipProductListModel vipModel = + VipProductListModel.fromJson(v, daichong); + list!.add(vipModel); + }); + } + // 仅 ACTIVE 时把实验上下文挂到卡上;DISABLED 时后端不带实验字段,也不 stamp + if (isExperimentActive) { + _stampAbExperiment(); + } + } + + void _stampAbExperiment() { + void stamp(VipProductModel p) { + p.experimentId = experimentId; + p.variant = variant; + } + + for (final group in list ?? []) { + for (final p in group.list ?? []) { + stamp(p); + } + } + for (final p in upgradeableVipCardList ?? []) { + stamp(p); + } + } + + Map toJson() { + final Map data = {}; + if (daichong != null) { + data['daichong'] = daichong!.toJson(); + } + if (integralList != null) { + data['integralList'] = integralList!.map((v) => v.toJson()).toList(); + } + data['isNewUser'] = isNewUser; + data['experimentStatus'] = experimentStatus; + data['experimentId'] = experimentId; + data['variant'] = variant; + data['defaultProductId'] = defaultProductId; + data['skinKey'] = skinKey; + if (uiConfig != null) data['uiConfig'] = uiConfig!.toJson(); + if (list != null) { + data['list'] = list!.map((v) => v.toJson()).toList(); + } + return data; + } +} + +class IntegralList { + String? desc; + int? duration; + String? id; + String? img; + String? name; + int? price; + int? type; + String? bgImg; + + IntegralList( + {this.desc, + this.duration, + this.id, + this.img, + this.name, + this.price, + this.type}); + + IntegralList.fromJson(Map json) { + desc = json['desc']; + duration = json['duration']; + id = json['id']; + img = json['img']; + name = json['name']; + price = json['price']; + bgImg = json['bgImg']; + type = json['type']; + } + + Map toJson() { + final Map data = {}; + data['desc'] = desc; + data['duration'] = duration; + data['id'] = id; + data['img'] = img; + data['name'] = name; + data['price'] = price; + data['type'] = type; + return data; + } +} + +class VipProductListModel { + List? list; + String? position; + String? positionID; + int? showType; + + VipProductListModel( + {this.list, this.position, this.positionID, this.showType}); + + VipProductListModel.fromJson(Map json, DCModel? daichong) { + if (json['list'] != null) { + list = []; + json['list'].forEach((v) { + VipProductModel pModel = VipProductModel.fromJson(v); + pModel.daichong = daichong; + list!.add(pModel); + }); + } + position = json['position']; + positionID = json['positionID']; + showType = json['showType']; + } + + Map toJson() { + final Map data = {}; + if (list != null) { + data['list'] = list!.map((v) => v.toJson()).toList(); + } + data['position'] = position; + data['positionID'] = positionID; + data['showType'] = showType; + return data; + } +} + +class VipProductModel { + String? actionDesc; + + /// 角标类型,如 MOST_POPULAR + String? badgeType; + + /// 角标文案,如「最受欢迎」 + String? badgeText; + int? aiUndressCount; + String? alias; + String? bgImg; + int? chanSplitMod; + int? chatPrice; + String? createdAt; + String? desc; + int? discountedPrice; + int? discountedPriceAnd; + int? discountedPriceIos; + int? downloadCount; + int? duration; + int? everyDayGiveCoin; + String? exclusiveOffer; + int? giveCoin; + int? giveFruitCoin; + int? goldVideoCouponCount; + int? goldVideoCouponNum; + int? goldVideoFreeDay; + bool? isAmountPay; // true 支持金币支付 + String? newBgImg; + String? newName; + List? newPrivilege; + int? originalPrice; + int? payVidDiscount; + String? position; + List? privilege; + String? privilegeDesc; + String? productID; + String? productName; + int? productType; //21- 预售卡 + List? rchgType; + int? serviceTime; + int? showCountdownTime; + int? signDays; + int? sort; + bool? status; + int? timesAWeek; + int? type; //会员卡类型 1、会员卡 2、礼包卡 + bool? unitPriceDisplay; + String? updatedAt; + int? videoDiscount; + String? vipCardDesc; + int? vipLevel; + String? tag; + DCModel? daichong; // 从上级数据结构手动赋值过来 + /// VIP 卡皮 A/B:由 /vip/product 响应挂到卡上,下单原样回传 + String? experimentId; + String? variant; + + //是否VIP升级 + bool? isUpgrade; + //当前VIP卡名称 + String? currentVipName; + //当前VIP卡价格 + String? currentVipPrice; + // 原价购买价格 + int? purchasePrice; + // 预付升级价格 + int? advanceAmount; + // 预售尾款 + int? balanceAmount; + + int get discountedPriceUI { + return (discountedPrice ?? 0) ~/ 10; + } // 现价 单位角(金币) + + int? prepaidPrice; + + int get prepaidPriceUI { + return (prepaidPrice ?? 0) ~/ 10; + } + + int get originalPriceUI { + return (originalPrice ?? 0) ~/ 10; + } // 现价 单位角(金币) + + bool isPreSale = false; + + // 预售新增字段 + String? endBgImg; + String? endBgSelectImg; + + List get rchgTypeUI { + List payList = []; + List payNameArr = [ + "支付宝(人工充值)", + "微信(人工充值)", + "银联(人工充值)", + "信用卡(人工充值)", + "花呗(人工充值)", + "云闪付(人工充值)", + "QQ錢包(人工充值)", + "京东支付(人工充值)" + ]; + for (RchgType rechargeTypeBean in (rchgType ?? [])) { + if (rechargeTypeBean.type == 'daichong') { + if (daichong?.traders?.isNotEmpty == true) { + PayForModel dcPayModel = daichong!.traders![0]; + if (dcPayModel.payInfos?.isNotEmpty == true) { + for (PayInfoModel payInfoModel in dcPayModel.payInfos!) { + var payType = RchgType(); + payType.isOfficial = true; + payType.channel = rechargeTypeBean.channel; + payType.incrAmount = rechargeTypeBean.incrAmount; + payType.incTax = rechargeTypeBean.incTax; + payType.payMethod = payInfoModel.payMethod; + if (payInfoModel.payMethod == 101) { + payType.type = 'alipy'; + payType.typeName = payNameArr[0]; + } else if (payInfoModel.payMethod == 102) { + payType.type = 'wechat'; + payType.typeName = payNameArr[1]; + } else if (payInfoModel.payMethod == 103) { + payType.type = 'union'; + payType.typeName = payNameArr[2]; + } else if (payInfoModel.payMethod == 104) { + payType.type = 'credit'; + payType.typeName = payNameArr[3]; + } else if (payInfoModel.payMethod == 105) { + payType.type = 'huabei'; + payType.typeName = payNameArr[4]; + } else if (payInfoModel.payMethod == 106) { + payType.type = 'yunSanPay'; + payType.typeName = payNameArr[5]; + } else if (payInfoModel.payMethod == 107) { + payType.type = 'qqWallet'; + payType.typeName = payNameArr[6]; + } else if (payInfoModel.payMethod == 108) { + payType.type = 'jindongPay'; + payType.typeName = payNameArr[7]; + } + payList.add(payType); + } + } + } + } else { + payList.add(rechargeTypeBean); + } + } + if (isAmountPay == true) { + RchgType coinType = RchgType(); + coinType.type = "coin"; + coinType.typeName = "金币"; + payList.add(coinType); + } + + return payList; + } + + VipProductModel({ + this.actionDesc, + this.badgeType, + this.badgeText, + this.aiUndressCount, + this.alias, + this.bgImg, + this.chanSplitMod, + this.chatPrice, + this.createdAt, + this.desc, + this.discountedPrice, + this.discountedPriceAnd, + this.discountedPriceIos, + this.downloadCount, + this.duration, + this.everyDayGiveCoin, + this.exclusiveOffer, + this.giveCoin, + this.giveFruitCoin, + this.goldVideoCouponCount, + this.goldVideoCouponNum, + this.goldVideoFreeDay, + this.isAmountPay, + this.newBgImg, + this.newName, + this.newPrivilege, + this.originalPrice, + this.payVidDiscount, + this.position, + this.privilege, + this.privilegeDesc, + this.productID, + this.productName, + this.productType, + this.rchgType, + this.serviceTime, + this.showCountdownTime, + this.signDays, + this.sort, + this.status, + this.timesAWeek, + this.type, + this.unitPriceDisplay, + this.updatedAt, + this.videoDiscount, + this.vipCardDesc, + this.vipLevel, + this.isPreSale = false, + this.tag, + this.isUpgrade, + this.currentVipName, + this.currentVipPrice, + this.purchasePrice, + this.advanceAmount, + }); + + VipProductModel.fromJson(Map json) { + prepaidPrice = json['prepaidPrice']; + endBgImg = json['endBgImg']; + endBgSelectImg = json['endBgSelectImg']; + actionDesc = json['actionDesc']; + badgeType = json['badgeType']; + badgeText = json['badgeText']; + aiUndressCount = json['aiUndressCount']; + alias = json['alias']; + bgImg = json['bgImg']; + chanSplitMod = json['chanSplitMod']; + chatPrice = json['chatPrice']; + createdAt = json['createdAt']; + desc = json['desc']; + discountedPrice = json['discountedPrice']; + discountedPriceAnd = json['discountedPriceAnd']; + discountedPriceIos = json['discountedPriceIos']; + downloadCount = json['downloadCount']; + duration = json['duration']; + everyDayGiveCoin = json['everyDayGiveCoin']; + exclusiveOffer = json['exclusiveOffer']; + giveCoin = json['giveCoin']; + giveFruitCoin = json['giveFruitCoin']; + goldVideoCouponCount = json['goldVideoCouponCount']; + goldVideoCouponNum = json['goldVideoCouponNum']; + goldVideoFreeDay = json['goldVideoFreeDay']; + isAmountPay = json['isAmountPay']; + newBgImg = json['newBgImg']; + newName = json['newName']; + tag = json['tag']; + if (json['newPrivilege'] != null) { + newPrivilege = []; + json['newPrivilege'].forEach((v) { + newPrivilege!.add(NewPrivilege.fromJson(v)); + }); + } + originalPrice = json['originalPrice']; + payVidDiscount = json['payVidDiscount']; + position = json['position']; + if (json['privilege'] != null) { + privilege = (json['privilege'] as List?)?.map((v) { + return int.tryParse(v.toString()) ?? 0; + }).toList(); + } + privilegeDesc = json['privilegeDesc']; + productID = json['productID']; + productName = json['productName']; + productType = json['productType']; + if (json['rchgType'] != null) { + rchgType = []; + json['rchgType'].forEach((v) { + rchgType!.add(RchgType.fromJson(v)); + }); + } + serviceTime = json['serviceTime']; + showCountdownTime = json['showCountdownTime']; + signDays = json['signDays']; + sort = json['sort']; + status = json['status']; + timesAWeek = json['timesAWeek']; + type = json['type']; + unitPriceDisplay = json['unitPriceDisplay']; + updatedAt = json['updatedAt']; + videoDiscount = json['videoDiscount']; + vipCardDesc = json['vipCardDesc']; + vipLevel = json['vipLevel']; + isPreSale = false; + isUpgrade = json['isUpgrade']; + currentVipName = json['currentVipName']; + currentVipPrice = "${json['currentVipPrice']}"; + purchasePrice = json['purchasePrice']; + advanceAmount = json['advanceAmount']; + balanceAmount = json['balanceAmount']; + } + + //是否有折扣 + bool hasDiscout() { + return discountedPrice != originalPrice; + } + + Map toJson() { + final Map data = {}; + data['actionDesc'] = actionDesc; + data['badgeType'] = badgeType; + data['badgeText'] = badgeText; + data['aiUndressCount'] = aiUndressCount; + data['alias'] = alias; + data['bgImg'] = bgImg; + data['chanSplitMod'] = chanSplitMod; + data['chatPrice'] = chatPrice; + data['createdAt'] = createdAt; + data['desc'] = desc; + data['discountedPrice'] = discountedPrice; + data['discountedPriceAnd'] = discountedPriceAnd; + data['discountedPriceIos'] = discountedPriceIos; + data['downloadCount'] = downloadCount; + data['duration'] = duration; + data['everyDayGiveCoin'] = everyDayGiveCoin; + data['exclusiveOffer'] = exclusiveOffer; + data['giveCoin'] = giveCoin; + data['giveFruitCoin'] = giveFruitCoin; + data['goldVideoCouponCount'] = goldVideoCouponCount; + data['goldVideoCouponNum'] = goldVideoCouponNum; + data['goldVideoFreeDay'] = goldVideoFreeDay; + data['isAmountPay'] = isAmountPay; + data['newBgImg'] = newBgImg; + data['newName'] = newName; + if (newPrivilege != null) { + data['newPrivilege'] = newPrivilege!.map((v) => v.toJson()).toList(); + } + data['originalPrice'] = originalPrice; + data['payVidDiscount'] = payVidDiscount; + data['position'] = position; + if (privilege != null) { + data['privilege'] = privilege!.map((v) => v).toList(); + } + data['privilegeDesc'] = privilegeDesc; + data['productID'] = productID; + data['productName'] = productName; + data['productType'] = productType; + if (rchgType != null) { + data['rchgType'] = rchgType!.map((v) => v.toJson()).toList(); + } + data['serviceTime'] = serviceTime; + data['showCountdownTime'] = showCountdownTime; + data['signDays'] = signDays; + data['sort'] = sort; + data['status'] = status; + data['timesAWeek'] = timesAWeek; + data['type'] = type; + data['unitPriceDisplay'] = unitPriceDisplay; + data['updatedAt'] = updatedAt; + data['videoDiscount'] = videoDiscount; + data['vipCardDesc'] = vipCardDesc; + data['vipLevel'] = vipLevel; + data['isUpgrade'] = isUpgrade; + data['currentVipName'] = currentVipName; + data['currentVipPrice'] = currentVipPrice; + data['purchasePrice'] = purchasePrice; + data['advanceAmount'] = advanceAmount; + data['balanceAmount'] = balanceAmount; + return data; + } +} + +extension PreSale on VipProductModel { + String realNormalVipImage() { + if (!isPreSale) return bgImg ?? ''; + if (PreSaleProvider().canPayBalance) return endBgImg ?? ''; + return bgImg ?? ''; + } + + String realSelectVipImage() { + if (!isPreSale) return newBgImg ?? ''; + if (PreSaleProvider().canPayBalance) return endBgSelectImg ?? ''; + return newBgImg ?? ''; + } +} + +class NewPrivilege { + String? id; + String? img; + String? privilegeDesc; + String? privilegeName; + int? privilege; + String? uncheckedImg; + bool? isCore; //是否核心权益(true 进「我的核心权益」横向卡片,false 进「更多权益」网格) + + NewPrivilege({this.img, this.privilegeDesc, this.privilegeName}); + + NewPrivilege.fromJson(Map json) { + id = json['id']; + img = json['img']; + privilegeDesc = json['privilegeDesc']; + privilegeName = json['privilegeName']; + privilege = json['privilege']; + uncheckedImg = json['uncheckedImg']; + isCore = json['isCore']; + } + + Map toJson() { + final Map data = {}; + data['id'] = id; + data['img'] = img; + data['privilegeDesc'] = privilegeDesc; + data['privilegeName'] = privilegeName; + data['uncheckedImg'] = uncheckedImg; + data['privilege'] = privilege; + data['isCore'] = isCore; + return data; + } +} + +class RchgType { + String? channel; //渠道类型 鲨鱼 金鱼 + int? incTax; //按比率增加额外优惠额 0-1之间 ,如果 incrAmount 与 incrTax 同时存在 以 incrAmount 为准 + int? incrAmount; //增加的优惠额度 + String? type; //充值方式 //coin 金币方式 + String? typeName; //支付宝,微信,银联 + + //daichong 业务字段 + bool? isOfficial = false; // 官方推荐 + int? payMethod; + + RchgType( + {this.channel, this.incTax, this.incrAmount, this.type, this.typeName}); + + RchgType.fromJson(Map json) { + channel = json['channel']; + incTax = json['incTax']; + incrAmount = json['incrAmount']; + type = json['type']; + typeName = json['typeName']; + } + + Map toJson() { + final Map data = {}; + data['channel'] = channel; + data['incTax'] = incTax; + data['incrAmount'] = incrAmount; + data['type'] = type; + data['typeName'] = typeName; + return data; + } + + String getPayIcon() { + if (isOfficial == true) { + // 代充 + return "pay_icon103.png".mineImgPath; + } else if (type == 'alipay') { + return "ic_alipay.png".mineImgPath; + } else if (type == 'union') { + return "pay_icon103.png".mineImgPath; + } else if (type == 'wechat') { + return "ic_wechat.png".mineImgPath; //AssetsSvg.SVG_PAY_ICON102; + } else if (type == 'coin') { + //金币 + return "ic_coin.webp".mineImgPath; //AssetsSvg.SVG_PAY_ICON102; + } else if (type == 'usdt') { + return "ic_usdt.png".mineImgPath; + } else { + return ""; + // return "ic_coupon.png".mineImgPath; + } + } +} + +/// 加赠券 + +class CouponModel { + String? cId; + int? count; + String? createTime; + String? expireTime; + String? id; + String? name; + int? price; + int? type; + bool? used; + int? value; + + CouponModel( + {this.cId, + this.count, + this.createTime, + this.expireTime, + this.id, + this.name, + this.price, + this.type, + this.used, + this.value}); + + CouponModel.fromJson(Map json) { + cId = json['cId']; + count = json['count']; + createTime = json['createTime']; + expireTime = json['expireTime']; + id = json['id']; + name = json['name']; + price = json['price']; + type = json['type']; + used = json['used']; + value = json['value']; + } + + Map toJson() { + final Map data = new Map(); + data['cId'] = this.cId; + data['count'] = this.count; + data['createTime'] = this.createTime; + data['expireTime'] = this.expireTime; + data['id'] = this.id; + data['name'] = this.name; + data['price'] = this.price; + data['type'] = this.type; + data['used'] = this.used; + data['value'] = this.value; + return data; + } +} diff --git a/lib/hj_page/mine/mine_vip/vip_ui_kit.dart b/lib/hj_page/mine/mine_vip/vip_ui_kit.dart new file mode 100644 index 0000000..f0945d1 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/vip_ui_kit.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +/// VIP 金色标题渐变 #F5E7BC → #DCAD55 +/// 会员中心 / 购买弹窗 / 权益标题等金色文案共用,调色只改此处 +const LinearGradient kVipTitleGradient = LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xffF5E7BC), Color(0xffDCAD55)], +); + +/// 金渐变着色包装:给文字 / 线条 / 菱形套上 [kVipTitleGradient] +class VipGradientMask extends StatelessWidget { + final Widget child; + + const VipGradientMask({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => kVipTitleGradient.createShader(bounds), + child: child, + ); + } +} + +/// B 组「核心权益」标题图(会员中心 / 购买弹窗共用) +class VipCoreSectionTitleImage extends StatelessWidget { + const VipCoreSectionTitleImage({super.key}); + + @override + Widget build(BuildContext context) { + return Image.asset( + 'vip_special_title.webp'.mineImgPath, + width: double.infinity, + fit: BoxFit.fitWidth, + ); + } +} + +/// B 组「更多权益」标题图(会员中心 / 购买弹窗共用) +class VipMoreSectionTitleImage extends StatelessWidget { + const VipMoreSectionTitleImage({super.key}); + + @override + Widget build(BuildContext context) { + return Image.asset( + 'vip_more_title.webp'.mineImgPath, + width: double.infinity, + fit: BoxFit.fitWidth, + ); + } +} + +/// 区块标题:两侧「金渐变横线 + 菱形」+ 居中金渐变文字(会员中心 B / 购买弹窗共用) +class VipSectionTitle extends StatelessWidget { + final String text; + + /// true:旧版橙色标题(会员中心 A / DISABLED) + final bool classic; + + const VipSectionTitle(this.text, {super.key, this.classic = false}); + + static const _classicOrange = Color(0xffF68804); + + @override + Widget build(BuildContext context) { + if (classic) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _classicDeco(lineFirst: true), + 8.sizeBoxW, + Text(text, + style: const TextStyle( + color: _classicOrange, + fontSize: 18, + fontWeight: FontWeight.w600)), + 8.sizeBoxW, + _classicDeco(lineFirst: false), + ], + ); + } + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildDeco(lineFirst: true), + 8.sizeBoxW, + VipGradientMask( + child: Text( + text, + style: const TextStyle( + color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600), + ), + ), + 8.sizeBoxW, + _buildDeco(lineFirst: false), + ], + ); + } + + Widget _classicDeco({required bool lineFirst}) { + final line = Container(width: 40, height: 0.5, color: _classicOrange); + final diamond = Transform.rotate( + angle: 0.7853981633974483, + child: Container(width: 5, height: 5, color: _classicOrange), + ); + return Row( + mainAxisSize: MainAxisSize.min, + children: lineFirst ? [line, diamond] : [diamond, line], + ); + } + + // 标题两侧装饰:金渐变细横线 + 菱形(lineFirst=true 横线在外侧,false 镜像) + Widget _buildDeco({required bool lineFirst}) { + const line = VipGradientMask( + child: SizedBox( + width: 40, height: 0.5, child: ColoredBox(color: Colors.white)), + ); + return Row( + mainAxisSize: MainAxisSize.min, + children: lineFirst ? [line, _diamond] : [_diamond, line], + ); + } + + // 金渐变小菱形装饰(45° 旋转的小方块) + Widget get _diamond => Transform.rotate( + angle: 0.7853981633974483, + child: const VipGradientMask( + child: SizedBox( + width: 5, height: 5, child: ColoredBox(color: Colors.white)), + ), + ); +} diff --git a/lib/hj_page/mine/mine_vip/widgets/coin_item.dart b/lib/hj_page/mine/mine_vip/widgets/coin_item.dart new file mode 100644 index 0000000..ed59e26 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/widgets/coin_item.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/mine/exchange/recharge_type_list_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +// 金币充值档位卡片 +class CoinItem extends StatelessWidget { + final RechargeTypeModel model; + final bool isSelected; // 是否选中 + final VoidCallback? onTap; + + const CoinItem(this.model, {super.key, this.onTap, this.isSelected = false}); + + @override + Widget build(BuildContext context) { + final couponDesc = model.couponDesc; + return GestureDetector( + onTap: onTap, + child: Stack( + children: [ + Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 10), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .1), + borderRadius: BorderRadius.circular(8), + border: isSelected + ? Border.all(width: 1, color: const Color(0xffF9C142)) + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset('coin_icon.webp'.mineImgPath, width: 36), + 4.sizeBoxH, + Text( + "${model.amount}金币", + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + 4.sizeBoxH, + Text( + '¥${model.moneyYuan}', + style: const TextStyle( + color: Color(0xffF9C142), fontWeight: FontWeight.w500), + ), + 4.sizeBoxH, + ], + ), + ), + // 左上角优惠角标 + if (couponDesc?.isNotEmpty == true) + Positioned( + top: 0, + left: 0, + child: Container( + height: 20, + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5), + decoration: const BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + bottomRight: Radius.circular(12)), + color: Color(0xffFFD460), + ), + child: Text( + couponDesc!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xff292929), + fontWeight: FontWeight.w500, + height: 1, + fontSize: 10.0, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/mine_vip/widgets/coin_pay_bottom_sheet.dart b/lib/hj_page/mine/mine_vip/widgets/coin_pay_bottom_sheet.dart new file mode 100644 index 0000000..bbb3483 --- /dev/null +++ b/lib/hj_page/mine/mine_vip/widgets/coin_pay_bottom_sheet.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/hj_utils/widget_util.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; + +import '../../make_money/mine_withdrawal_record_page.dart'; +import '../mine_charge_coin_logic.dart'; +import '../pay_order_source.dart'; +import 'coin_item.dart'; + +/// 金币支付底部弹窗(按设计稿:余额卡片 + 档位网格 + 立即支付) +class CoinPayBottomSheet extends StatelessWidget { + /// 下单来源,默认播放页底部弹窗——本弹窗只在播放页(购买弹窗/解锁蒙层)用 + final PaySourcePage sourcePage; + + /// 下单埋点上下文(短剧付费墙充金币要带剧/集/付费墙上下文) + final PayOrderTrackInfo? orderTrack; + + const CoinPayBottomSheet( + {super.key, + this.sourcePage = PaySourcePage.videoBottomSheet, + this.orderTrack}); + + /// 弹窗展示中,防连点叠多个 bottomSheet + static bool _isShowing = false; + + static Future show({ + PaySourcePage sourcePage = PaySourcePage.videoBottomSheet, + PayOrderTrackInfo? orderTrack, + }) async { + if (_isShowing) return null; + _isShowing = true; + try { + return await Get.bottomSheet( + CoinPayBottomSheet(sourcePage: sourcePage, orderTrack: orderTrack), + isScrollControlled: true, + backgroundColor: Colors.transparent, + ); + } finally { + _isShowing = false; + } + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MineChargeCoinLogic(sourcePage: sourcePage, orderTrack: orderTrack), + global: false, + builder: (logic) { + // Material 填深色底:圆角抗锯齿对着 #040018,避免透明底导致左右上角异色 + return Material( + color: const Color(0xff040018), + borderRadius: const BorderRadius.vertical(top: Radius.circular(18)), + clipBehavior: Clip.antiAlias, + child: Container( + constraints: BoxConstraints(maxHeight: Get.height * 0.85), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xff040018), Color(0xff060606)], + ), + ), + child: Stack( + children: [ + // 顶部径向黄光:#FACC15 16% → 0% + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment.topCenter, + radius: 1.2, + colors: [ + Color(0x29FACC15), // #FACC15 @ 16% + Color(0x00FACC15), // #FACC15 @ 0% + ], + ), + ), + ), + ), + SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 12.sizeBoxH, + const SheetHandleBar(), + 16.sizeBoxH, + const Text( + '金币支付', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + 16.sizeBoxH, + Flexible(child: _buildBody(logic)), + _buildBottomBar(logic), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildBody(MineChargeCoinLogic logic) { + if (logic.isInitLoading) { + return const SizedBox(height: 220, child: LoadingCenterWidget()); + } + if (logic.model?.list?.isEmpty != false) { + return SizedBox( + height: 220, + child: CErrorWidget(retryOnTap: () => logic.loadData()), + ); + } + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + _buildWallet(), + 16.sizeBoxH, + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: 111 / 138, + ), + itemCount: logic.model!.list!.length, + itemBuilder: (context, index) { + final model = logic.model!.list![index]; + final isSelected = logic.selectedCoin?.id == model.id; + return CoinItem( + model, + isSelected: isSelected, + onTap: () => logic.onSelectCoin(index), + ); + }, + ), + 18.sizeBoxH, + ], + ), + ); + } + + /// 我的金币余额卡片 + Widget _buildWallet() { + return Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '我的金币余额', + style: TextStyle( + color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + 8.sizeBoxH, + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset('coin_icon.webp'.mineImgPath, width: 36), + 4.sizeBoxW, + Consumer( + builder: (_, store, __) { + final wallet = store.wallet; + final total = (wallet?.amount ?? 0) + (wallet?.income ?? 0); + return Text( + '$total', + style: const TextStyle( + color: Color(0xffFFD460), + fontSize: 32, + fontWeight: FontWeight.w600, + ), + ); + }, + ), + const Spacer(), + InkWell( + enableFeedback: false, + onTap: () => + Get.to(RecordsPage(RecordType.bill), opaque: false), + child: Container( + alignment: Alignment.center, + height: 30, + width: 90, + decoration: BoxDecoration( + color: const Color(0xffFFD460), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + '余额明细', + style: TextStyle(color: Color(0xff3D3D3D), fontSize: 14), + ), + ), + ), + ], + ), + ], + ), + ); + } + + /// 立即支付 + 在线客服 + Widget _buildBottomBar(MineChargeCoinLogic logic) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => logic.onGotoPay(), + child: Container( + height: 44, + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppColors.actionRed, + borderRadius: BorderRadius.circular(6), + ), + alignment: Alignment.center, + child: Text( + '¥${logic.selectedCoin?.moneyYuan ?? 0}/立即支付', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + 12.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('支付问题反馈,点击联系 ', + style: textStyle(12, const Color(0xffBFBFC1), FontWeight.w400)), + GestureDetector( + onTap: pushToCustomService, + child: Text('在线客服', + style: + textStyle(12, const Color(0xffFFD460), FontWeight.w400)), + ), + ], + ), + 16.sizeBoxH, + ], + ); + } +} diff --git a/lib/hj_page/mine/more_question/feedback_question_category_view.dart b/lib/hj_page/mine/more_question/feedback_question_category_view.dart new file mode 100644 index 0000000..2d2a548 --- /dev/null +++ b/lib/hj_page/mine/more_question/feedback_question_category_view.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +class FeedbackQuestionCategoryView extends StatefulWidget { + final Function(String category)? choseCategory; + const FeedbackQuestionCategoryView({super.key, this.choseCategory}); + + @override + State createState() => + _FeedbackQuestionCategoryViewState(); +} + +class _FeedbackQuestionCategoryViewState + extends State { + final dataSource = [ + '账号问题', + '影视资源', + 'APP体验', + '播放失败', + '播放卡顿', + '分类有误', + '充值问题', + '其他' + ]; + String? selectCategory; + @override + Widget build(BuildContext context) { + return GridView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 12, + crossAxisSpacing: 5, + childAspectRatio: 84 / 34), + itemCount: dataSource.length, + itemBuilder: (BuildContext context, int index) { + final category = dataSource[index]; + final select = category == selectCategory; + return GestureDetector( + onTap: () { + selectCategory = category; + setState(() {}); + widget.choseCategory?.call(category); + }, + child: Container( + decoration: BoxDecoration( + color: select + ? Color(0xFFF68804) + : Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(3), + ), + alignment: Alignment.center, + child: Text( + category, + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ); + }, + ); + } +} + +class InfomationInputView extends StatefulWidget { + final String title; + final String hint; + final TextEditingController controller; + const InfomationInputView(this.controller, + {super.key, this.title = '', this.hint = ''}); + + @override + State createState() => _InfomationInputViewState(); +} + +class _InfomationInputViewState extends State { + @override + Widget build(BuildContext context) { + return Column( + children: [ + 12.sizeBoxH, + Row( + children: [ + SizedBox( + width: 90, + child: Text( + widget.title, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ), + Expanded( + child: TextField( + maxLines: null, + style: TextStyle(color: Colors.white, fontSize: 12), + maxLength: 20, + controller: widget.controller, + decoration: InputDecoration( + border: InputBorder.none, + hintText: widget.hint, + hintStyle: + TextStyle(color: Color(0xff525252), fontSize: 12), + counterText: '', + contentPadding: EdgeInsets.zero, + isDense: true), + ), + ) + ], + ), + 12.sizeBoxH, + Divider( + height: 0.5, + color: Colors.black.withValues(alpha: 0.05), + ) + ], + ); + } +} diff --git a/lib/hj_page/mine/more_question/mine_feedback_logic.dart b/lib/hj_page/mine/more_question/mine_feedback_logic.dart new file mode 100644 index 0000000..66dfc1b --- /dev/null +++ b/lib/hj_page/mine/more_question/mine_feedback_logic.dart @@ -0,0 +1,95 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/file_upload/file_upload_tool.dart'; +import 'package:hgdj/tools_base/loading/loading_alert_widget.dart'; + +class MineFeedbackLogic extends GetxController { + /// 问题描述 + TextEditingController inputFeedback = TextEditingController(); + + /// 区域 + TextEditingController areaFeedback = TextEditingController(); + + /// 设备 + TextEditingController deviceFeedback = TextEditingController(); + + /// 网络运营商 + TextEditingController netFeedback = TextEditingController(); + + ///联系方式 + TextEditingController contactFeedback = TextEditingController(); + + ///封面本地地址 + List _localPicList = []; + + String _questionCategory = ''; + + @override + void onClose() { + inputFeedback.dispose(); + areaFeedback.dispose(); + deviceFeedback.dispose(); + netFeedback.dispose(); + contactFeedback.dispose(); + super.onClose(); + } + + /// 主要问题 + updateQuestionCategory(String category) => _questionCategory = category; + + /// 资源图片变化 + updateQuestionImages(List images) => _localPicList = images; + + Future onSubmit() async { + if (_questionCategory.isEmpty) { + showToast("请选择遇到的问题分类"); + return; + } + if (inputFeedback.text.isEmpty) { + showToast("请填写问题描述"); + return; + } + + // 图片可选:无图直接提交;有图先上传再提交,上传失败仍照常提交(不阻断反馈) + if (_localPicList.isEmpty) { + _submitFeedback([]); + return; + } + FileUploadTool().uploadImagesWithProgress( + _localPicList, + onSuccess: (urls) => _submitFeedback(urls), + onFailure: () => _submitFeedback([]), + ); + } + + /// 提交反馈([images] 为已上传的图片 url,可为空) + Future _submitFeedback(List images) async { + LoadingAlertWidget.show(title: "正在提交..."); + try { + FocusScope.of(Get.context!).unfocus(); + final success = await MineService.feedback(inputFeedback.text, + location: areaFeedback.text, + device: deviceFeedback.text, + carrier: netFeedback.text, + img: images, + fType: _questionCategory, + contact: contactFeedback.text); + if (success) { + Get.back(); + showToast('提交成功'); + } else { + showToast('提交失败'); + } + } on DioException catch (e) { + showToast(e.message ?? ''); + } catch (e) { + showToast(e.toString()); + } finally { + LoadingAlertWidget.cancel(); + } + } +} diff --git a/lib/hj_page/mine/more_question/mine_feedback_page.dart b/lib/hj_page/mine/more_question/mine_feedback_page.dart new file mode 100644 index 0000000..14dcff2 --- /dev/null +++ b/lib/hj_page/mine/more_question/mine_feedback_page.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import 'feedback_question_category_view.dart'; +import 'mine_feedback_logic.dart'; +import 'mine_qa_page.dart'; +import 'photo_manage_view.dart'; + +class MineFeedbackPage extends StatelessWidget { + const MineFeedbackPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MineFeedbackLogic(), + builder: (controller) => Scaffold( + appBar: AppBar( + title: Text('意见反馈'), + actions: [ + InkWell( + enableFeedback: false, + onTap: () => Get.to(() => MineQAPage()), + child: Text( + 'Q&A', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.45), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ), + 16.sizeBoxW, + ], + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + child: Padding( + padding: EdgeInsets.only(left: 10, right: 10, top: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '遇到的问题', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + 12.sizeBoxH, + FeedbackQuestionCategoryView( + choseCategory: controller.updateQuestionCategory, + ), + 24.sizeBoxH, + Text( + '问题描述(必填)', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 18, + fontWeight: FontWeight.w600), + ), + 12.sizeBoxH, + Container( + constraints: BoxConstraints(minHeight: 160), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 11), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(8), + ), + child: Stack( + children: [ + TextField( + maxLines: 8, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 12), + maxLength: 300, + controller: controller.inputFeedback, + decoration: InputDecoration( + border: InputBorder.none, + hintText: + '请详细描述您的问题,无效信息无法帮助技术人员排查问题。
有效信息:机型,系统,地区,问题症状...', + hintStyle: TextStyle( + color: Color(0xff525252), fontSize: 12), + counterStyle: TextStyle(fontSize: 8), + counter: SizedBox(), + contentPadding: EdgeInsets.zero, + isDense: true), + ), + Positioned( + bottom: 0, + right: 0, + // 字数计数:只监听不接管所有权(释放归 MineFeedbackLogic.onClose) + child: ValueListenableBuilder( + valueListenable: controller.inputFeedback, + builder: (_, value, __) => Text( + '${value.text.length}/300', + style: TextStyle( + color: + Colors.black.withValues(alpha: 0.5), + fontSize: 12, + ), + ), + ), + ) + ], + ), + ), + 14.sizeBoxH, + InfomationInputView(controller.areaFeedback, + title: '所在地区', hint: '例:浙江杭州'), + InfomationInputView(controller.deviceFeedback, + title: '设备信息', hint: '例:苹果14'), + InfomationInputView(controller.netFeedback, + title: '网络运营商', hint: '电信/联通/移动'), + InfomationInputView(controller.contactFeedback, + title: '联系方式', hint: 'QQ/微信/邮箱等'), + 24.sizeBoxH, + Text( + '上传图片', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 18, + fontWeight: FontWeight.w700), + ), + 12.sizeBoxH, + PhotoManageView( + resourceOnchanged: controller.updateQuestionImages, + ), + 24.sizeBoxH, + Text( + '以方便我们给您回复,有效的改进建议,有惊喜赠送哟!', + style: + TextStyle(color: Color(0xff525252), fontSize: 12), + ), + 60.sizeBoxH, + ], + ), + )), + ), + GestureDetector( + onTap: controller.onSubmit, + child: Container( + margin: EdgeInsets.symmetric(horizontal: 45), + alignment: Alignment.center, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: AppColors.actionRed, + ), + child: Text( + '提交意见', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600), + ), + ), + ), + 20.sizeBoxH, + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/more_question/mine_qa_page.dart b/lib/hj_page/mine/more_question/mine_qa_page.dart new file mode 100644 index 0000000..f2b10a4 --- /dev/null +++ b/lib/hj_page/mine/more_question/mine_qa_page.dart @@ -0,0 +1,146 @@ +import 'dart:math'; + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; + +///帮助反馈 +class MineQAPage extends StatefulWidget { + const MineQAPage({super.key}); + @override + State createState() => _MineQAPageState(); +} + +class _MineQAPageState extends State { + final String tg = 'https://t.me/mu02guang'; + final String email = 'xzhan5555@gmail.com'; + final String url = 'XV9.FM'; + + late final questionList = [ + { + "question": "描述文件安装失败?", + "answer": "若提示【新的MDM有效负载与旧的有效负载不匹配】,请移除移动设备管理步骤:【设置-通用-设备管理-移动设备管理-移除管理】", + 'isOpen': true + }, + { + "question": "怎么找回账号?", + "answer": + "本平台登录会自动创建账号,需保存账号凭证或绑定手机号码,才能记录之前的账号信息。受行业限制,APP无法正常使用时需升级,未绑定手机号码会导致账号信息丢失。请及时绑定手机号码或保存账号凭证,以免VIP信息丢失,造成巨大财产损失!账号丢失的用户可在 『我的』页面-账号找回,原账号的VIP信息会转移至新账号上。『账号凭证』『手机绑定』都没有的情况下,如账号VIP信息丢失,可以提供VIP支付充值凭证截图联系在线客服为您查询核实恢复VIP。", + 'isOpen': false + }, + { + "question": "怎么支付不成功?", + "answer": + "1.因超时支付无法到账,请重新发起。\n2.每天发起支付不能超过5次,连续发起且未支付,账号可能被加入黑名单。\n3.支付通道在夜间比较忙碌,尝试多次发起,后台会为您自动切换不同支付通道。\n4.若充值成功,用户权益通常会在数分钟内到账,请刷新APP或重启。\n6.若支付成功并重启APP后依然没有到账,请联系在线客服并提供付款成功凭证截图。", + 'isOpen': false + }, + { + "question": "收到手机报毒提醒?", + "answer": + "本平台有主要收益为广告赞助,且保证APP安全无毒,因平台主要展示内容为色情属于特殊行业,某些杀毒软件会误报毒提醒,如遇此类提醒请忽略继续使用。", + 'isOpen': false + }, + { + "question": "联系方式?", + "answer": "商务合作TG: $tg\n官方邮箱: $email\n永久下载地址: $url", + 'isOpen': false + }, + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("常见问题"), + ), + body: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 12, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...List.generate( + questionList.length, + (index) => _getItem(index), + ), + ], + ), + ), + ); + } + + Widget _getItem(int index) { + final item = questionList[index]; + final isOpen = item["isOpen"] as bool; + return InkWell( + enableFeedback: false, + onTap: () { + item["isOpen"] = !isOpen; + setState(() {}); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + item["question"] as String, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + Spacer(), + Transform.rotate( + angle: isOpen ? (pi * -0.5) : 0, + child: Icon( + Icons.arrow_forward_ios_rounded, + size: 14, + color: Colors.white, + ), + ), + ], + ), + if (isOpen) ...[ + 10.sizeBoxH, + EasyRichText( + '${item["answer"] as String}', + defaultStyle: TextStyle( + color: Color(0xff989898), + fontWeight: FontWeight.w400, + fontSize: 12, + ), + patternList: [ + EasyRichTextPattern( + targetString: tg, + style: TextStyle(color: AppColors.primaryHighColor), + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrlToWeb(tg); + }, + ), + EasyRichTextPattern( + targetString: url, + style: TextStyle(color: AppColors.primaryHighColor), + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrlToWeb('https://$url'); + }, + ), + ], + ), + ], + 12.sizeBoxH, + Divider( + height: .5, + color: Colors.black.withValues(alpha: .04), + ), + 19.sizeBoxH, + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/more_question/photo_manage_view.dart b/lib/hj_page/mine/more_question/photo_manage_view.dart new file mode 100644 index 0000000..0009d9c --- /dev/null +++ b/lib/hj_page/mine/more_question/photo_manage_view.dart @@ -0,0 +1,89 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:image_pickers/image_pickers.dart'; +import 'package:hgdj/tools_base/widget/add_media_source_button.dart'; + +class PhotoManageView extends StatefulWidget { + final Function(List resources)? resourceOnchanged; + final int max; + const PhotoManageView({super.key, this.resourceOnchanged, this.max = 9}); + + @override + State createState() => _PhotoManageViewState(); +} + +class _PhotoManageViewState extends State { + final dataSource = []; + @override + Widget build(BuildContext context) { + return GridView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1), + itemCount: min(dataSource.length + 1, 9), + itemBuilder: (BuildContext context, int index) { + if (index == dataSource.length) + return AddMediaSourceButton( + isVideo: false, + onTap: _addAlbumPhotos, + backgroundColor: Colors.black.withValues(alpha: .04), + ); + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + children: [ + Image.file( + File(dataSource[index]), + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ), + Positioned( + right: 6, + top: 6, + child: GestureDetector( + onTap: () { + dataSource.removeAt(index); + setState(() {}); + }, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .8), + borderRadius: BorderRadius.circular(10)), + alignment: Alignment.center, + child: const Icon( + Icons.close, + size: 14, + ), + ), + )) + ], + ), + ); + }, + ); + } + + _addAlbumPhotos() async { + // image_picker 走系统相册 intent,选图不需要存储权限,直接调起 + final listMedia = await ImagePickers.pickerPaths( + uiConfig: UIConfig(uiThemeColor: Colors.white), + galleryMode: GalleryMode.image, + selectCount: 9 - dataSource.length, + showCamera: false, + ); + if (listMedia.isEmpty) return; + final ret = listMedia.map((e) => e.path ?? '').toList(); + dataSource.addAll(ret); + setState(() {}); + widget.resourceOnchanged?.call(dataSource); + } +} diff --git a/lib/hj_page/mine/welfare/lou_feng_ad_logic.dart b/lib/hj_page/mine/welfare/lou_feng_ad_logic.dart new file mode 100644 index 0000000..32b55c0 --- /dev/null +++ b/lib/hj_page/mine/welfare/lou_feng_ad_logic.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; + +import '../../../hj_model/mine/happy/happy_model.dart'; +import '../../../hj_model/splash/ads_model.dart'; + +/// 应用推荐页:按分类拉广告位数据,每个分类一个 tab +class LouFengAdLogic extends GetxController + with GetSingleTickerProviderStateMixin { + bool isLoading = true; + HappyModel? model; // null 表示加载失败,页面展示重试 + + final tabs = []; // 有内容的分类名 + final configs = []; // 与 tabs 一一对应的渲染配置 + TabController? tabCtr; + + @override + void onInit() { + super.onInit(); + loadData(); + } + + @override + void onClose() { + tabCtr?.dispose(); + super.onClose(); + } + + /// 拉数据并重建分类,失败时 model 为 null 由页面兜底 + Future loadData() async { + model = await CommonService.happyList(); + isLoading = false; + tabs.clear(); + configs.clear(); + if (model != null) _buildTabs(); + update(); + } + + /// 「应用」固定展示,其余分类有 banner 或有应用才建 tab + void _buildTabs() { + _add( + '应用', + AdTabConfig( + bannerAds: _banners(1), + hengAds: model?.hengApp != null + ? AdGroup(items: model?.hengApp, title: '官方推荐') + : null, + shuAds: model?.shuApp != null + ? AdGroup(items: model?.shuApp, title: '热门应用') + : null, + )); + + final others = [ + ('炮台', 3, model?.ypApp), + ('棋牌', 4, model?.qpApp), + ('直播', 5, model?.zbApp), + ('游戏', 2, model?.gameApp), + ]; + for (final (name, moduleType, apps) in others) { + final banners = _banners(moduleType); + if (banners.isEmpty && (apps?.isEmpty ?? true)) continue; + _add( + name, + AdTabConfig( + bannerAds: banners, + shuAds: apps != null ? AdGroup(items: apps, title: '热门推荐') : null, + )); + } + + // tab 数量随数据变化,controller 要跟着重建 + tabCtr?.dispose(); + tabCtr = TabController(length: tabs.length, vsync: this); + } + + void _add(String name, AdTabConfig config) { + tabs.add(name); + configs.add(config); + } + + /// 取该分类下的 banner 广告 + List _banners(int moduleType) => + model?.adv?.where((it) => it.moduleType == moduleType).toList() ?? []; +} diff --git a/lib/hj_page/mine/welfare/lou_feng_ad_page.dart b/lib/hj_page/mine/welfare/lou_feng_ad_page.dart new file mode 100644 index 0000000..20b9d22 --- /dev/null +++ b/lib/hj_page/mine/welfare/lou_feng_ad_page.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import 'lou_feng_ad_logic.dart'; +import 'widget/recommend_app_page.dart'; + +/// 应用推荐页,可从广告链接再次进入(taskhall),故用 uniqueTag 隔离多实例 +class LouFengAdPage extends StatefulWidget { + const LouFengAdPage({super.key}); + + @override + State createState() => _LouFengAdPageState(); +} + +class _LouFengAdPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: LouFengAdLogic(), + builder: (logic) { + if (logic.isLoading) return const LoadingCenterWidget(); + if (logic.model == null) + return CErrorWidget(retryOnTap: logic.loadData); + //只有「应用」一个分类时不展示 tab + if (logic.tabs.length <= 1) return _adView(logic, 0); + + return Column( + children: [ + 14.sizeBoxH, + //分类 tab:无指示器,靠文字颜色区分选中态 + Container( + margin: EdgeInsets.symmetric(horizontal: 9), + child: TabBar( + controller: logic.tabCtr, + padding: EdgeInsets.zero, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelStyle: TextStyle(fontSize: 14, color: Color(0xE5FFFFFF)), + unselectedLabelStyle: + TextStyle(fontSize: 14, color: Color(0xffACBABF)), + indicatorSize: TabBarIndicatorSize.tab, + indicatorColor: Color(0x00FFFFFF), + indicatorWeight: 1, + labelColor: Color(0xE5FFFFFF), + labelPadding: EdgeInsets.zero, + tabs: logic.tabs + .map((it) => Padding( + padding: + EdgeInsets.symmetric(vertical: 5, horizontal: 10), + child: Text(it), + )) + .toList(), + ), + ), + Expanded( + child: TabBarView( + controller: logic.tabCtr, + children: List.generate( + logic.configs.length, (i) => _adView(logic, i).keepAlive), + ), + ), + ], + ); + }, + ); + } + + Widget _adView(LouFengAdLogic logic, int index) => + RecommendAppPage(logic.configs[index]); +} diff --git a/lib/hj_page/mine/welfare/mine_income_page.dart b/lib/hj_page/mine/welfare/mine_income_page.dart new file mode 100644 index 0000000..9ed8d32 --- /dev/null +++ b/lib/hj_page/mine/welfare/mine_income_page.dart @@ -0,0 +1,199 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../config/config.dart'; +import '../mine_share/mine_share_record_logic.dart'; +import '../mine_share/mine_share_record_page.dart'; +import '../widgets/mine_share_qr_view.dart'; + +class MineIncomePage extends StatefulWidget { + const MineIncomePage({super.key}); + + @override + State createState() => _MineIncomePageState(); +} + +class _MineIncomePageState extends State with UniqueTagMixin { + @override + void initState() { + super.initState(); + globalStore.refreshWallet(); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: MineShareRecordLogic(), + builder: (logic) => SingleChildScrollView( + child: Container( + padding: EdgeInsets.only(left: 0, right: 0, top: 0), + child: Column( + children: [ + Stack( + children: [ + Positioned.fill( + child: Image.asset("proxy_header_bg.webp".mineImgPath, + fit: BoxFit.fill), + ), + Container( + padding: EdgeInsets.fromLTRB(10, 23, 10, 6), + // decoration: BoxDecoration(color: Color(0xFF030F18)), + child: Column( + children: [ + Consumer( + builder: (_, store, __) => Row( + children: [ + NetworkImageLoader( + imageUrl: store.meInfo?.portrait ?? '', + width: 60, + height: 60, + borderRadius: 30, + ), + 10.sizeBoxW, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + globalStore.meInfo?.name ?? "", + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontWeight: FontWeight.w700, + fontSize: 18.0), + ), + 5.sizeBoxH, + Text( + "开通会员 畅享专属特权", + style: const TextStyle( + color: Color(0xff989898), + fontWeight: FontWeight.w400, + fontSize: 12), + ) + ], + ), + Spacer(), + GestureDetector( + onTap: () { + Get.to(() => MineShareRecordPage()); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "邀请人数", + style: const TextStyle( + color: Color(0xB2FFFFFF), + fontWeight: FontWeight.w500, + fontSize: 12.0), + ), + SizedBox(height: 1), + Text( + "${logic.model?.totalInviteUserCount ?? 0}", + style: const TextStyle( + color: Color(0xffF68804), + fontWeight: FontWeight.w500, + fontSize: 24.0), + ) + ], + ), + ) + ], + ), + ), + if (Config.proxyBanner?.additionalProp != null) + Column( + children: [ + 29.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () { + pushToPageByLink( + Config.proxyBanner?.additionalProp?.url ?? + ""); + }, + child: Config.proxyBanner?.additionalProp + ?.banner != + null + ? NetworkImageLoader( + imageUrl: Config.proxyBanner + ?.additionalProp?.banner ?? + '', + width: double.infinity, + borderRadius: 0, + ) + : Image.asset( + 'proxy_banner.webp'.mineImgPath, + width: double.infinity, + ), + ), + ], + ) + ], + ), + ), + ], + ), + 12.sizeBoxH, + Container( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Row( + children: [ + Text( + "规则说明", + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontWeight: FontWeight.bold, + fontSize: 18.0), + ), + ], + ), + ), + Container( + padding: EdgeInsets.symmetric(vertical: 5, horizontal: 10), + width: double.infinity, + child: EasyRichText( + '每邀请3名好友成功注册即可获得3天VIP', + textAlign: TextAlign.left, + defaultStyle: + TextStyle(fontSize: 12, color: Color(0x73FFFFFF)), + patternList: [ + EasyRichTextPattern( + targetString: '3名好友', + style: TextStyle(color: Color(0xFFF68804)), + ), + ], + ), + ), + Container( + padding: EdgeInsets.symmetric(vertical: 5, horizontal: 10), + width: double.infinity, + child: EasyRichText( + '邀请说明:点击【保存二维码】或【复制推广链接】分享给朋友下载即可', + textAlign: TextAlign.left, + defaultStyle: + TextStyle(fontSize: 12, color: Color(0x73FFFFFF)), + ), + ), + MineShareQRView(), + 12.sizeBoxH, + Image.asset( + "invite_steps.webp".mineImgPath, + fit: BoxFit.fill, + ), + 24.sizeBoxH, + ], + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/welfare/more_free_video_logic.dart b/lib/hj_page/mine/welfare/more_free_video_logic.dart new file mode 100644 index 0000000..54cc53c --- /dev/null +++ b/lib/hj_page/mine/welfare/more_free_video_logic.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +//免费专区排序 tab(标题与接口 sort 值绑定) +const _sortTabs = [SortTab('最多收藏', 1), SortTab('最新上架', 2), SortTab('最多观看', 3)]; + +class MoreFreeVideoLogic extends GetxController + with GetTickerProviderStateMixin { + final String vId; + List get sortTitles => _sortTabs.map((e) => e.name).toList(); + MoreFreeVideoLogic(this.vId); + + bool isLoading = true; + int page = 1; + int sort = 0; + late final TabController tabController = + TabController(length: _sortTabs.length, vsync: this); + RefreshController? refreshController; + List dataSource = []; + + fetchPageData({bool isRefresh = true, bool showLoading = false}) async { + if (isRefresh) { + page = 1; + } + if (showLoading) { + isLoading = true; + update(); + } + final res = await VidService.fetchFreeSourceList(vId, + page: page, sortType: _sortTabs[sort].sort); + isLoading = false; + if (isRefresh) { + dataSource.clear(); + refreshController?.refreshCompleted(); + } + res?.hasNext ?? false + ? refreshController?.loadComplete() + : refreshController?.loadNoData(); + dataSource.addAll(res?.list ?? []); + page += 1; + update(); + } +} diff --git a/lib/hj_page/mine/welfare/more_free_video_page.dart b/lib/hj_page/mine/welfare/more_free_video_page.dart new file mode 100644 index 0000000..2dcbb6f --- /dev/null +++ b/lib/hj_page/mine/welfare/more_free_video_page.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../../home/home_cell_style/video_simple_cell.dart'; +import 'more_free_video_logic.dart'; + +class MoreFreeVideoPage extends StatelessWidget { + final String title; + final String vId; + const MoreFreeVideoPage(this.title, this.vId, {super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MoreFreeVideoLogic(vId), + builder: (controller) => Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Column( + children: [ + Container( + padding: EdgeInsets.symmetric(vertical: 12), + child: Container( + width: 182, + height: 31, + padding: EdgeInsets.all(2), + decoration: ShapeDecoration( + shape: StadiumBorder(), + color: Color(0xFF20252F), + ), + child: TabBar( + tabAlignment: TabAlignment.fill, + tabs: controller.sortTitles.map((e) { + return Container( + alignment: Alignment.center, + child: Text(e), + ); + }).toList(), + labelStyle: TextStyle( + color: Color(0xFFF1F3F4), + fontSize: 12, + ), + unselectedLabelStyle: TextStyle( + color: Color(0xFFACBABF), + fontSize: 12, + ), + controller: controller.tabController, + padding: EdgeInsets.zero, + isScrollable: false, + onTap: (index) { + controller.sort = index; + controller.fetchPageData(showLoading: true); + }, + labelPadding: EdgeInsets.zero, + indicator: ShapeDecoration( + shape: StadiumBorder(), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFF83A4F8), Color(0xFF2A5CDE)], + ), + ), + ), + ), + ), + Expanded( + child: pullYsRefresh( + onInit: (ctr) => controller.refreshController = ctr, + onRefresh: (_) => controller.fetchPageData(), + onLoading: (_) => controller.fetchPageData(isRefresh: false), + child: () { + if (controller.isLoading) return LoadingCenterWidget(); + if (controller.dataSource.isEmpty) return CErrorWidget(); + return GridView.builder( + padding: EdgeInsets.symmetric(horizontal: 10), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 7, + mainAxisSpacing: 12, + childAspectRatio: 191 / 174, + ), + itemCount: controller.dataSource.length, + itemBuilder: (BuildContext context, int index) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: VideoSimpleCell( + videoModel: controller.dataSource[index], + ), + ); + }, + ); + }(), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/welfare/share_data_list_page.dart b/lib/hj_page/mine/welfare/share_data_list_page.dart new file mode 100644 index 0000000..6a5b969 --- /dev/null +++ b/lib/hj_page/mine/welfare/share_data_list_page.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/user/user_income_info_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; + +import '../make_money/withdrawal_page.dart'; +import 'share_details_page.dart'; + +class IncomeData { + String value; + String title; + + IncomeData(this.title, this.value); +} + +class ShareDataListPage extends StatefulWidget { + const ShareDataListPage({super.key}); + + @override + State createState() => _ShareDataListPageState(); +} + +class _ShareDataListPageState extends State { + UserIncomeModel? _model; + bool isLoading = true; + final incomeList = []; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + final res = await MineService.fetchIncomeInfo(); + isLoading = false; + _model = res; + incomeList.add(IncomeData("当月收益(元)", _model?.monthIncomeAmount ?? '0')); + incomeList.add(IncomeData("当月推广数", _model?.monthInviteUserCount ?? '0')); + incomeList.add(IncomeData("今日收益(元)", _model?.todayIncomeAmount ?? '0')); + incomeList.add(IncomeData("今日推广数", _model?.todayInviteUserCount ?? '0')); + if (mounted) setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('推广数据'), + actions: [ + GestureDetector( + onTap: () => Get.to(() => ShareDetailsPage(model: _model)), + child: Text( + '收益明细', + style: TextStyle(color: Color(0xFF989898), fontSize: 12), + ), + ), + 16.sizeBoxW + ], + ), + body: () { + if (isLoading) return LoadingCenterWidget(); + if (_model == null) return CErrorWidget(); + return Padding( + padding: EdgeInsets.only(left: 12, right: 12, top: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 160, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: + Border.all(color: const Color(0xff1e262e), width: 1), + color: Color(0xFF131b23)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "钱包余额", + style: const TextStyle( + color: const Color(0xffb8bbbd), + fontWeight: FontWeight.w900, + fontSize: 12.0), + ), + Text( + "${_model?.totalAmount}", + style: const TextStyle( + color: const Color(0xffffffff), + fontWeight: FontWeight.w500, + fontSize: 24.0), + ) + ], + ), + ), + SizedBox( + height: 57, + width: .5, + child: DecoratedBox( + decoration: + BoxDecoration(color: Color(0xff444444))), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "累积收益", + style: const TextStyle( + color: Color(0xffb8bbbd), + fontWeight: FontWeight.w900, + fontSize: 12.0), + ), + Text( + "${_model?.totalIncomeAmount}", + style: const TextStyle( + color: Color(0xffffffff), + fontWeight: FontWeight.w500, + fontSize: 24.0), + ) + ], + ), + ), + ], + ), + ), + InkWell( + enableFeedback: false, + onTap: () => Get.to(WithdrawalPage()), + child: Container( + width: 276, + height: 36, + margin: EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(18)), + color: Color(0xFFcf452f), + ), + child: Center( + child: Text( + "立即提现", + style: const TextStyle( + color: const Color(0xffffffff), + fontWeight: FontWeight.w900, + fontSize: 12.0), + ), + ), + ), + ) + ], + ), + ), + 12.sizeBoxH, + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(6)), + border: + Border.all(color: const Color(0xff1e262e), width: 1), + color: Color(0xFF131b23), + ), + child: GridView.count( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + crossAxisCount: 2, + childAspectRatio: 5 / 3, + children: + incomeList.map((e) => _buildIncomeGItem(e)).toList(), + ), + ), + 15.sizeBoxH, + Text( + "推广总统计", + style: const TextStyle( + color: const Color(0xffffffff), + fontWeight: FontWeight.w600, + fontSize: 16.0), + ), + 15.sizeBoxH, + _buildTGItem("累计推广用户", _model?.totalInviteUserCount ?? '0'), + _buildTGItem("累计付费用户", _model?.totalPayUserCount ?? '0'), + ], + ), + ); + }()); + } + + Widget _buildTGItem(String title, String value) { + return Container( + height: 35, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: const TextStyle( + color: const Color(0xffb8bbbd), + fontWeight: FontWeight.w400, + fontSize: 12.0), + ), + Text( + "$value", + style: const TextStyle( + color: const Color(0xffffffff), + fontWeight: FontWeight.w500, + fontSize: 18.0), + ), + ], + ), + ); + } + + Widget _buildIncomeGItem(IncomeData item) { + return Container( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${item.value}", + style: const TextStyle( + color: const Color(0xffffffff), + fontWeight: FontWeight.w900, + fontSize: 18.0), + ), + Text( + item.title, + style: const TextStyle( + color: const Color(0xffb8bbbd), + fontWeight: FontWeight.w400, + fontSize: 12.0), + ) + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/welfare/share_details_page.dart b/lib/hj_page/mine/welfare/share_details_page.dart new file mode 100644 index 0000000..b1bd6a2 --- /dev/null +++ b/lib/hj_page/mine/welfare/share_details_page.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/user/user_income_info_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import 'share_data_list_page.dart'; + +class ShareDetailsPage extends StatefulWidget { + final UserIncomeModel? model; + + const ShareDetailsPage({super.key, this.model}); + + @override + State createState() => _ShareDetailsPageState(); +} + +class _ShareDetailsPageState extends State { + RefreshController? _controller; + final incomeList = []; + int page = 1; + bool isLoading = true; + final dataSource = []; + + @override + void initState() { + super.initState(); + final model = widget.model; + incomeList.add(IncomeData("总推广人数", model?.totalInviteUserCount ?? '0')); + incomeList.add(IncomeData("总推广收益", model?.totalIncomeAmount ?? '0')); + incomeList.add(IncomeData("今日推广人数", model?.todayInviteUserCount ?? '0')); + incomeList.add(IncomeData("今日推广收益", model?.todayIncomeAmount ?? '0')); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _fetchPageData(); + }); + } + + _fetchPageData({bool isRefresh = true}) async { + if (isRefresh) { + page = 1; + } + final res = await MineService.fetchIncomeList(page: 1); + isLoading = false; + if (isRefresh) { + dataSource.clear(); + } + res?.hasNext ?? false + ? _controller?.loadComplete() + : _controller?.loadNoData(); + dataSource.addAll(res?.items ?? []); + page += 1; + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('推广数据'), + ), + body: Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: pullYsRefresh( + onLoading: (_) => _fetchPageData(isRefresh: false), + onRefresh: (_) => _fetchPageData(), + child: CustomScrollView( + slivers: [ + SliverList.separated( + itemCount: incomeList.length, + itemBuilder: (_, index) => _buildIncomeItem(incomeList[index]), + separatorBuilder: (_, index) => 10.sizeBoxH, + ), + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 10), + // 收益明细 + Text( + "收益明细", + style: const TextStyle( + color: const Color(0xffbec4d6), + fontWeight: FontWeight.w500, + fontSize: 18.0), + ), + SizedBox(height: 10), + ], + ), + ), + () { + if (isLoading) + return SliverToBoxAdapter( + child: SizedBox( + height: 300, + child: LoadingCenterWidget(), + ), + ); + if (dataSource.isEmpty) + return SliverToBoxAdapter( + child: SizedBox(height: 300, child: CErrorWidget())); + return SliverList.builder( + itemCount: dataSource.length, + itemBuilder: (_, index) { + final item = dataSource[index]; + return _buildRecordItem(item.userName, item.incomeAmount); + }); + }() + ], + ), + onInit: (_) => _controller = _, + ), + ), + ); + } + + Widget _buildRecordItem(String title, int value) { + return Container( + height: 35, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: const TextStyle( + color: const Color(0xffffffff), + fontWeight: FontWeight.w400, + fontSize: 14.0), + ), + Text( + "收益+$value元", + style: const TextStyle( + color: const Color(0xffF68804), + fontWeight: FontWeight.w400, + fontSize: 14.0), + ), + ], + ), + ); + } + + Widget _buildIncomeItem(IncomeData item) { + return Container( + height: 65, + padding: EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(4)), + border: Border.all(color: const Color(0xff1e262e), width: 1), + color: Color(0xFF131b23), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + item.title, + style: const TextStyle( + color: const Color(0xE5FFFFFF), + fontWeight: FontWeight.w400, + fontSize: 14.0), + ), + Text( + "${item.value}", + style: const TextStyle( + color: const Color(0x8CFFFFFF), + fontWeight: FontWeight.w500, + fontSize: 20.0), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/welfare/sign_daily_logic.dart b/lib/hj_page/mine/welfare/sign_daily_logic.dart new file mode 100644 index 0000000..ad33e4a --- /dev/null +++ b/lib/hj_page/mine/welfare/sign_daily_logic.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../hj_model/mine/task_center_data.dart'; +import '../../../hj_utils/api_service/common_service.dart'; +import '../../../hj_utils/api_service/mine_service.dart'; + +class SignDailyPageLogic extends GetxController with GetTickerProviderStateMixin { + int shareCount = 0; + List? taskList; + List? dailyTask; + final outerCtr = ScrollController(); + final tabKey = GlobalKey(); + String signBackgroundImage = ''; + + final tabs = ["福利任务", "积分兑换"]; + + late final TabController tabCtr = TabController( + initialIndex: 0, + length: tabs.length, + vsync: this, + ); + + /// AppBar 背景透明度,由滚动驱动:0.0 透明 → 1.0 不透明 + double appbarOpacity = 0.0; + + /// 滚动多少像素后 AppBar 完全不透明 + static const double _kAppBarFadeMaxOffset = 100.0; + + @override + void onInit() { + super.onInit(); + outerCtr.addListener(_onOuterScroll); + } + + @override + void onReady() { + super.onReady(); + loadData(); + } + + @override + void onClose() { + outerCtr.removeListener(_onOuterScroll); + outerCtr.dispose(); + tabCtr.dispose(); + super.onClose(); + } + + /// 监听外层滚动,按偏移量更新 AppBar 透明度并局部刷新 + void _onOuterScroll() { + final newOpacity = (outerCtr.offset / _kAppBarFadeMaxOffset).clamp(0.0, 1.0); + if ((newOpacity - appbarOpacity).abs() < 0.01) return; + appbarOpacity = newOpacity; + update(['appbar']); + } + + ///初始化任务列表 + Future loadData() async { + final signResult = await MineService.getSignList(); + signBackgroundImage = signResult?.config?.backgroundImage ?? ''; + + final result = await CommonService.getTaskList(); + taskList ??= []; + taskList?.clear(); + + result?.dailyTask?.forEach((element) { + element.doType = 1; + }); + taskList?.addAll(result?.dailyTask ?? []); + + result?.growthTasks?.forEach((element) { + element.doType = 3; + }); + taskList?.addAll(result?.growthTasks ?? []); + + result?.onceTask?.forEach((element) { + element.doType = 2; + }); + taskList?.addAll(result?.onceTask ?? []); + + update(); + } +} diff --git a/lib/hj_page/mine/welfare/sign_daily_page.dart b/lib/hj_page/mine/welfare/sign_daily_page.dart new file mode 100644 index 0000000..45aa5d4 --- /dev/null +++ b/lib/hj_page/mine/welfare/sign_daily_page.dart @@ -0,0 +1,288 @@ +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/mine/widgets/integral_exchange_page.dart'; +import 'package:hgdj/hj_utils/free_play_manager.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../tools_base/global_store/store.dart'; +import '../../../tools_base/indicator/custom_tab_indicator.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import '../../main_page/provider/msg_provider.dart'; +import '../../pre_sale/pre_sale_provider.dart'; +import '../mine_vip/mine_charge_vip_page.dart'; +import '../widgets/exchange_vip_page.dart'; +import 'sign_daily_logic.dart'; +import 'widget/task_widgets.dart'; + +//每日签到 +class SignDailyPage extends StatelessWidget { + const SignDailyPage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: SignDailyPageLogic(), + global: false, + builder: (logic) { + return Scaffold( + extendBodyBehindAppBar: true, + appBar: _buildAppBar(logic), + body: _buildBody(logic), + ); + }, + ); + } + + /// AppBar:背景透明度跟随滚动变化(局部刷新 id='appbar') + PreferredSizeWidget _buildAppBar(SignDailyPageLogic logic) { + return PreferredSize( + preferredSize: const Size.fromHeight(kToolbarHeight), + child: GetBuilder( + init: logic, + global: false, + id: 'appbar', + builder: (_) => AppBar( + title: const Text('每日签到'), + backgroundColor: + const Color(0xff030F18).withValues(alpha: logic.appbarOpacity), + elevation: 0, + ), + ), + ); + } + + Widget _buildBody(SignDailyPageLogic logic) { + if (logic.taskList == null) return const LoadingCenterWidget(); + if (logic.taskList!.isEmpty) return const CErrorWidget(); + + return ExtendedNestedScrollView( + controller: logic.outerCtr, + physics: const BouncingScrollPhysics(), + onlyOneScrollInBody: true, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + CupertinoSliverRefreshControl( + onRefresh: () async { + await logic.loadData(); + }, + ), + SliverToBoxAdapter(child: _buildVIPNewGuide(logic)), + SliverToBoxAdapter(child: MineSignView(logic: logic)), + SliverToBoxAdapter( + child: Container( + key: logic.tabKey, + margin: const EdgeInsets.fromLTRB(16, 15, 0, 6), + child: _buildTab(logic), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + '我的积分 ', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w400), + ), + Consumer( + builder: (_, provider, __) => Text( + '${globalStore.wallet?.integral ?? 0}', + style: const TextStyle( + color: Color(0xffF68804), + fontSize: 24, + fontWeight: FontWeight.w700), + ), + ), + ], + ), + ), + ), + ]; + }, + body: TabBarView( + controller: logic.tabCtr, + children: [ + MineTaskView(logic.taskList!).keepAlive, + const IntegralExchangePage().keepAlive, + ], + ), + ); + } + + _buildTab(SignDailyPageLogic logic) { + return Row( + children: [ + TabBar( + padding: EdgeInsets.zero, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelStyle: const TextStyle( + fontSize: 16, + color: Color(0xffF68804), + fontWeight: FontWeight.w500), + unselectedLabelStyle: const TextStyle( + fontSize: 16, + color: Color(0xff999999), + ), + controller: logic.tabCtr, + tabs: logic.tabs.map((it) => Text(it)).toList(), + labelPadding: const EdgeInsets.fromLTRB(0, 0, 20, 6), + indicator: CustomIndicator( + width: 18, + height: 4, + borderRadius: const BorderRadius.all(Radius.circular(2)), + ), + ), + const Spacer(), + GestureDetector( + onTap: () => Get.to(ExchangeVipPage()), + child: const Padding( + padding: EdgeInsets.only(right: 16), + child: Text( + '兑换记录', + style: TextStyle( + color: Color(0xFF999999), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + )) + ], + ); + } + + _buildVIPNewGuide(SignDailyPageLogic logic) { + return Stack( + children: [ + logic.signBackgroundImage.isNotEmpty + ? NetworkImageLoader( + width: double.infinity, + height: 240, + fit: BoxFit.cover, + imageUrl: logic.signBackgroundImage, + ) + : Image.asset( + "welfare_vip_background.webp".mineImgPath, + width: double.infinity, + height: 240, + fit: BoxFit.fitWidth, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 110.sizeBoxH, + Consumer( + builder: (context, provider, child) => Container( + margin: const EdgeInsets.only(left: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + NetworkImageLoader( + imageUrl: globalStore.meInfo?.portrait ?? '', + width: 60, + height: 60, + borderRadius: 30, + ), + 10.sizeBoxW, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + globalStore.meInfo?.name ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500), + ), + if ((globalStore.meInfo?.vipLevel ?? 0) > 0) ...[ + 4.sizeBoxW, + Image.asset( + globalStore.meInfo?.vipImageName ?? "", + width: 30, + height: 18), + ], + ], + ), + 5.sizeBoxH, + Consumer( + builder: (context, provider, child) { + final isVip = globalStore.isVIP; + final watch = + FreePlayManager().remain?.watchCount ?? 0; + final aiFree = + globalStore.wallet?.aiUndressFreeTimes ?? 0; + final aiToday = + provider.remain?.todayAiUndressCount ?? 0; + return Text( + isVip + ? "剩余AI次数:${aiFree + aiToday}" + : "剩余观看次数:$watch", + style: const TextStyle( + fontSize: 12, color: Color(0xff656565)), + ); + }, + ) + ], + ), + ], + ), + ), + ), + 22.sizeBoxH, + if (!globalStore.isVIP) + GestureDetector( + onTap: () { + Get.to(MineChargeVipPage()); + }, + child: Container( + height: 44, + width: double.infinity, + alignment: Alignment.center, + padding: + EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.h), + margin: EdgeInsets.symmetric(horizontal: 40.w, vertical: 0), + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(90)), + gradient: LinearGradient( + colors: [Color(0xFFFF9077), Color(0xFFFF6E4E)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Text( + globalStore.isVIP ? '已开通会员' : '开通VIP不限次数免费观看', + style: TextStyle( + color: Color( + globalStore.isVIP ? 0xfffffffff : 0xfffffffff), + fontSize: 16, + fontWeight: FontWeight.w700), + ), + ), + ), + if (globalStore.isVIP) TaskCenterVIPGuideView(), + 20.sizeBoxW, + ], + ), + ) + ], + ); + } +} diff --git a/lib/hj_page/mine/welfare/welfare_home_page.dart b/lib/hj_page/mine/welfare/welfare_home_page.dart new file mode 100644 index 0000000..d27933f --- /dev/null +++ b/lib/hj_page/mine/welfare/welfare_home_page.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../../../tools_base/indicator/custom_tab_indicator.dart'; +import 'lou_feng_ad_page.dart'; +import 'mine_income_page.dart'; + +class WelfareHomePage extends StatefulWidget { + ///默认选中的 tab:0 分享邀请 / 1 应用推荐 + final int index; + + const WelfareHomePage({super.key, this.index = 0}); + + @override + State createState() { + return _WelfareHomePageState(); + } +} + +class _WelfareHomePageState extends State + with TickerProviderStateMixin { + final tabs = ['分享邀请', "应用推荐"]; + late final TabController _tabController = TabController( + initialIndex: widget.index, length: tabs.length, vsync: this); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, // 不自动显示返回按钮 + toolbarHeight: 56, // 高度保持默认 + titleSpacing: 0, // 去除左右空隙 + title: Row( + children: [ + // 返回按钮 + IconButton( + icon: Icon(Icons.arrow_back_ios), + onPressed: () => Get.back(), + ), + Expanded( + child: TabBar( + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 5), + isScrollable: false, + labelStyle: TextStyle( + fontSize: 14, + color: Color(0xE5FFFFFF), + fontWeight: FontWeight.w400), + unselectedLabelStyle: + TextStyle(fontSize: 14, color: Color(0x73FFFFFF)), + indicator: CustomIndicator( + width: 18, + height: 4, + isGradient: true, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(2), + topRight: Radius.circular(2), + bottomLeft: Radius.circular(0), + bottomRight: Radius.circular(2), + ), + gradientColors: const [Color(0x55F68804), Color(0xffF68804)], + offsetY: -8, + ), + controller: _tabController, + tabs: tabs.map((it) => Text(it)).toList(), + labelPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 2), + ), + ), + SizedBox(width: 54), + ], + ), + ), + body: Column( + children: [ + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + MineIncomePage().keepAlive, + LouFengAdPage().keepAlive, + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/welfare/widget/checkin_model.dart b/lib/hj_page/mine/welfare/widget/checkin_model.dart new file mode 100644 index 0000000..e4a44a7 --- /dev/null +++ b/lib/hj_page/mine/welfare/widget/checkin_model.dart @@ -0,0 +1,277 @@ +/// 签到列表响应模型 +class CheckinPrizeResp { + CheckinInfo? checkin; + CheckinConfig? config; + List? prizes; + List? bigPrizes; + + CheckinPrizeResp({this.checkin, this.config, this.prizes}); + + CheckinPrizeResp.fromJson(Map json) { + checkin = json['checkin'] != null + ? CheckinInfo.fromJson(json['checkin']) + : null; + config = json['config'] != null + ? CheckinConfig.fromJson(json['config']) + : null; + if (json['prizes'] != null) { + prizes = []; + json['prizes'].forEach((v) { + prizes!.add(CheckinPrize.fromJson(v)); + }); + } + if (json['bigPrizes'] != null) { + bigPrizes = []; + json['bigPrizes'].forEach((v) { + bigPrizes!.add(CheckinPrize.fromJson(v)); + }); + } + } + + Map toJson() { + final Map data = {}; + if (checkin != null) { + data['checkin'] = checkin!.toJson(); + } + if (config != null) { + data['config'] = config!.toJson(); + } + if (prizes != null) { + data['prizes'] = prizes!.map((v) => v.toJson()).toList(); + } + if (bigPrizes != null) { + data['bigPrizes'] = bigPrizes!.map((v) => v.toJson()).toList(); + } + return data; + } +} + +/// 签到信息 +class CheckinInfo { + int? continuouslyDays; // 连续签到天数 + int? cumulativeDays; // 累计签到天数(本月) + bool? todayChecked; // 今日是否已经签过到 + bool? doubleReward; + CheckinInfo({this.continuouslyDays, this.cumulativeDays, this.todayChecked,this.doubleReward}); + + CheckinInfo.fromJson(Map json) { + continuouslyDays = json['continuouslyDays']; + cumulativeDays = json['cumulativeDays']; + todayChecked = json['todayChecked']; + doubleReward=json['doubleReward']; + } + + Map toJson() { + final Map data = {}; + data['continuouslyDays'] = continuouslyDays; + data['cumulativeDays'] = cumulativeDays; + data['todayChecked'] = todayChecked; + data['doubleReward']=doubleReward; + return data; + } +} + +/// 签到配置 +class CheckinConfig { + String? backgroundImage; // 背景图片 + String? description; // 规则说明 + bool? enable; // 是否启用 + String? rewardBgVideoUrl; //视频 + List? integerExchangeList; + + CheckinConfig({this.backgroundImage, this.description, this.enable,this.rewardBgVideoUrl}); + + CheckinConfig.fromJson(Map json) { + backgroundImage = json['backgroundImage']; + description = json['description']; + enable = json['enable']; + rewardBgVideoUrl = json['rewardBgVideoUrl']; + if (json['integerExchangeList'] != null) { + integerExchangeList = []; + json['integerExchangeList'].forEach((v) { + integerExchangeList!.add(IntegerExchange.fromJson(v)); + }); + } + } + + Map toJson() { + final Map data = {}; + data['backgroundImage'] = backgroundImage; + data['description'] = description; + data['enable'] = enable; + data['rewardBgVideoUrl'] = rewardBgVideoUrl; + if (integerExchangeList != null) { + data['integerExchangeList'] = integerExchangeList!.map((v) => v.toJson()).toList(); + } + return data; + } +} + + +class IntegerExchange{ + String? name; + String? icon; + IntegerExchange({this.name, this.icon}); + IntegerExchange.fromJson(Map json) { + name = json['name']; + icon = json['icon']; + } + + Map toJson() { + final Map data = {}; + data['name'] = name; + data['icon'] = icon; + return data; + } +} +/// 签到奖励 +class CheckinPrize { + bool? bigPrize; // 大奖,有问题提示的 + int? checkinDays; // 签到天数 + int? checkinType; // 签到类型 Enum: 0, 1, 2 + String? createdAt; + String? id; // ID + String? image; // 图片 + String? prizeId; // 奖品ID + bool? status; // 状态(是否已领取) + String? title; // 标题 + String? updatedAt; + int? score; // 签到奖励积分数量 + String? prizeName; + bool? isCheckedIn; + bool? canClaim;//是否可以补领 + bool? isReceive;//是否可以补领 + bool? isExpired;//是否过期 + + CheckinPrize({ + this.bigPrize, + this.checkinDays, + this.checkinType, + this.createdAt, + this.id, + this.image, + this.prizeId, + this.status, + this.title, + this.updatedAt, + this.score, + this.prizeName, + this.isCheckedIn, + this.isReceive, + this.canClaim, + this.isExpired, + }); + + CheckinPrize.fromJson(Map json) { + bigPrize = json['bigPrize']; + checkinDays = json['checkinDays']; + checkinType = json['checkinType']; + createdAt = json['createdAt']; + id = json['id']; + image = json['image']; + prizeId = json['prizeId']; + status = json['status']; + title = json['title']; + updatedAt = json['updatedAt']; + score = json['score']; + prizeName = json['prizeName']; + isCheckedIn = json['isCheckedIn']; + canClaim = json['canClaim']; + isReceive = json['isReceive']; + isExpired = json['isExpired']; + } + + Map toJson() { + final Map data = {}; + data['bigPrize'] = bigPrize; + data['checkinDays'] = checkinDays; + data['checkinType'] = checkinType; + data['createdAt'] = createdAt; + data['id'] = id; + data['image'] = image; + data['prizeId'] = prizeId; + data['status'] = status; + data['title'] = title; + data['updatedAt'] = updatedAt; + data['score'] = score; + data['prizeName'] = prizeName; + data['isCheckedIn'] = isCheckedIn; + data['canClaim'] = canClaim; + data['isReceive'] = isReceive; + data['isExpired'] = isExpired; + return data; + } +} + +/// 执行签到响应 +class CheckinDoResp { + CheckinInfo? checkin; + String? message; + List? prizes; + String? prizeVideo; + + CheckinDoResp({this.checkin, this.message, this.prizes}); + + CheckinDoResp.fromJson(Map json) { + checkin = json['checkin'] != null + ? CheckinInfo.fromJson(json['checkin']) + : null; + message = json['message']; + prizeVideo = json['prizeVideo']; + if (json['prizes'] != null) { + prizes = []; + json['prizes'].forEach((v) { + prizes!.add(CheckinRewardPrize.fromJson(v)); + }); + } + } + + Map toJson() { + final Map data = {}; + if (checkin != null) { + data['checkin'] = checkin!.toJson(); + } + data['message'] = message; + data['prizeVideo'] = prizeVideo; + if (prizes != null) { + data['prizes'] = prizes!.map((v) => v.toJson()).toList(); + } + return data; + } +} + +/// 签到奖励详情 +class CheckinRewardPrize { + bool? countRand; + int? prizeCount; + String? prizeImage; + String? prizeTitle; + int? prizeType; + + CheckinRewardPrize({ + this.countRand, + this.prizeCount, + this.prizeImage, + this.prizeTitle, + this.prizeType, + }); + + CheckinRewardPrize.fromJson(Map json) { + countRand = json['countRand']; + prizeCount = json['prizeCount']; + prizeImage = json['prizeImage']; + prizeTitle = json['prizeTitle']; + prizeType = json['prizeType']; + } + + Map toJson() { + final Map data = {}; + data['countRand'] = countRand; + data['prizeCount'] = prizeCount; + data['prizeImage'] = prizeImage; + data['prizeTitle'] = prizeTitle; + data['prizeType'] = prizeType; + return data; + } +} + diff --git a/lib/hj_page/mine/welfare/widget/congratulations_reward_dialog.dart b/lib/hj_page/mine/welfare/widget/congratulations_reward_dialog.dart new file mode 100644 index 0000000..8466231 --- /dev/null +++ b/lib/hj_page/mine/welfare/widget/congratulations_reward_dialog.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:video_player/video_player.dart'; + +import 'checkin_model.dart'; + +class CongratulationsRewardDialog extends StatelessWidget { + final VoidCallback? onExchange; + final List? prizeList; + // final String? videoUrl; + // final String? videoToken; + final VideoPlayerController? videoPlayerCtr; + const CongratulationsRewardDialog({ + super.key, + this.onExchange, + this.prizeList, + // this.videoUrl, + // this.videoToken, + this.videoPlayerCtr, + }); + + @override + Widget build(BuildContext context) { + // 不包 Dialog / 不铺满屏幕 → 点击 content 之外的区域由 Get.dialog 的 barrierDismissible 关闭 + return Container( + padding: EdgeInsets.symmetric(horizontal: 27), + alignment: Alignment.center, + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 356, + width: 319, + child: VideoPlayer(videoPlayerCtr!), + ), + SizedBox(height: 18), + Text( + "- 积分可兑换以下豪礼 -", + style: TextStyle(color: Color(0xffFFD900), fontSize: 14), + ), + SizedBox(height: 12), + // 奖励列表 + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.start, + children: prizeList + ?.map((e) => _buildRewardItem(e.name ?? "", e.icon ?? "")) + .toList() ?? + [], + ), + SizedBox(height: 36), + // 底部按钮 + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + Get.back(); + onExchange?.call(); + }, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: const Color(0xFFFFD700), width: 2), + ), + child: Text( + "兑换好礼", + style: TextStyle( + color: const Color(0xFFFFD700), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: () => Get.back(), + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Color(0xffFFD900), + borderRadius: BorderRadius.circular(24), + ), + child: Text( + "继续领积分", + style: TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ).paddingSymmetric(horizontal: 33), + ], + ), + ), + ); + } + + Widget _buildRewardItem(String title, String iconPath) { + return Column( + children: [ + Container( + width: 53, + height: 53, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: .5), width: 0.5)), + padding: EdgeInsets.all(6), + child: NetworkImageLoader(imageUrl: iconPath, fit: BoxFit.contain), + ), + SizedBox(height: 6), + SizedBox( + width: 65, + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/mine/welfare/widget/custom_progressbar.dart b/lib/hj_page/mine/welfare/widget/custom_progressbar.dart new file mode 100644 index 0000000..be5b9f9 --- /dev/null +++ b/lib/hj_page/mine/welfare/widget/custom_progressbar.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +class CustomProgressBar extends StatelessWidget { + final double progress; // 0 ~ 1 + + const CustomProgressBar({super.key, required this.progress}); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + double width = constraints.maxWidth; + return Stack( + alignment: Alignment.centerLeft, + children: [ + SizedBox(height: 18), + // 背景 + Container( + height: 6, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .1), + borderRadius: BorderRadius.circular(8), + ), + ), + + // 进度 + Container( + height: 6, + width: width * progress, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Color(0xffF68804), + ), + ), + // 小图标(跟随进度,两端不溢出) + Positioned( + left: (width - 18) * progress, + child: Image.asset("ic_sign_indicator.webp".mineImgPath, + width: 18, height: 18, fit: BoxFit.cover), + ), + ], + ); + }, + ), + ), + 8.sizeBoxW, + // 右侧百分比 + Text( + "${(progress * 100).toInt()}%", + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/welfare/widget/mine_sign_logic.dart b/lib/hj_page/mine/welfare/widget/mine_sign_logic.dart new file mode 100644 index 0000000..1d930d0 --- /dev/null +++ b/lib/hj_page/mine/welfare/widget/mine_sign_logic.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/address.dart'; +import 'package:hgdj/hj_page/mine/mine_share/mine_share_page.dart'; +import 'package:hgdj/hj_page/mine/welfare/sign_daily_logic.dart'; +import 'package:hgdj/hj_page/mine/welfare/widget/checkin_model.dart'; +import 'package:hgdj/hj_page/mine/welfare/widget/congratulations_reward_dialog.dart'; +import 'package:hgdj/hj_page/mine/welfare/widget/sign_dialog.dart'; +import 'package:hgdj/hj_page/mine/welfare/widget/sign_in_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:linked_scroll_controller/linked_scroll_controller.dart'; +import 'package:video_player/video_player.dart'; +import 'package:hgdj/hj_utils/video_view_type.dart'; + +/// 每日签到 Logic +/// 负责签到列表加载、签到/补领接口、双倍奖励视频播放、跳转积分兑换 Tab +class MineSignLogic extends GetxController { + /// 父级页面 Logic(用于切 Tab + 滚动到 Tab 位置) + final SignDailyPageLogic? parentLogic; + MineSignLogic({this.parentLogic}); + + // ========== 状态 ========== + bool isSigned = false; // 今日是否签到 + bool doubleReward = false; // 是否触发双倍奖励 + int continuousDays = 0; // 连续签到天数 + int cumulativeDays = 0; // 累计签到天数(本月) + String? videoUrl; // 双倍奖励视频 ID + List? checkList; // 普通签到奖励 + List? vipCheckList; // 会员签到奖励(大礼) + List? prizeLists; // 积分兑换列表 + final extraRewardList = []; // 额外签到奖励 + + // ========== 滚动控制器(三排联动) ========== + late final LinkedScrollControllerGroup scrollGroup; + late final ScrollController titleScrollCtr; // 第 0 排:天数标题 + late final ScrollController normalScrollCtr; // 第 1 排:普通奖励 + late final ScrollController vipScrollCtr; // 第 2 排:会员奖励 + VideoPlayerController? videoPlayerCtr; + + // ========== 生命周期 ========== + @override + void onInit() { + super.onInit(); + scrollGroup = LinkedScrollControllerGroup(); + titleScrollCtr = scrollGroup.addAndGet(); + normalScrollCtr = scrollGroup.addAndGet(); + vipScrollCtr = scrollGroup.addAndGet(); + getSignList(); + getExtralSignList(); + } + + @override + void onClose() { + titleScrollCtr.dispose(); + normalScrollCtr.dispose(); + vipScrollCtr.dispose(); + videoPlayerCtr?.dispose(); + super.onClose(); + } + + // ========== 公开方法 ========== + + /// 获取签到主列表(普通 + 会员奖励 + 积分兑换配置) + Future getSignList() async { + final res = await MineService.getSignList(); + if (res == null) return; + isSigned = res.checkin?.todayChecked ?? false; + continuousDays = res.checkin?.continuouslyDays ?? 0; + cumulativeDays = res.checkin?.cumulativeDays ?? 0; + doubleReward = res.checkin?.doubleReward ?? false; + prizeLists = res.config?.integerExchangeList; + checkList = res.prizes; + vipCheckList = res.bigPrizes; + update(); + } + + /// 获取额外签到奖励列表 + Future getExtralSignList() async { + try { + final res = await MineService.getExtraDayMark(); + if (res != null) { + extraRewardList + ..clear() + ..addAll(res); + update(); + } + } catch (_) {} + } + + /// 点击签到按钮 + Future doSignIn() async { + if (isSigned) { + showToast('今日已签到'); + return; + } + final res = await MineService.postDayMark(); + if (res?.checkin == null) return; + showToast('签到成功'); + // 同步签到状态 + continuousDays = res!.checkin!.continuouslyDays ?? 0; + cumulativeDays = res.checkin!.cumulativeDays ?? 0; + isSigned = res.checkin!.todayChecked ?? false; + doubleReward = res.checkin!.doubleReward ?? false; + videoUrl = res.prizeVideo ?? ''; + getSignList(); + update(); + globalStore.refreshWallet(); + // 第 8 天起仅 toast 不弹窗;前 7 天:双倍奖励弹特效,否则弹普通签到弹窗 + if (continuousDays > 7) return; + if (doubleReward) { + _showDoubleRewardDialog(); + } else { + _showNormalSignDialog(); + } + } + + /// 会员奖励补领 + Future doCheckinClaimVip() async { + final resp = await MineService.claimCheckinVip(); + if (resp?.checkin == null) { + showToast(resp?.message ?? '补领失败'); + return; + } + showToast(resp?.message ?? '补领成功'); + continuousDays = resp!.checkin!.continuouslyDays ?? 0; + cumulativeDays = resp.checkin!.cumulativeDays ?? 0; + isSigned = resp.checkin!.todayChecked ?? false; + doubleReward = resp.checkin!.doubleReward ?? false; + videoUrl = resp.prizeVideo ?? ''; + update(); + await getSignList(); + globalStore.refreshWallet(); + } + + /// 补签:调用接口 + 弹"邀请分享"引导弹窗 + Future doResign(SignInListItem item) async { + await MineService.postReSign(item.id ?? ''); + Get.dialog( + _ResignGuideDialog( + onConfirm: () { + Get.back(); + Get.to(() => MineSharePage()); + }, + onCancel: Get.back, + ), + ); + } + + /// 切到"积分兑换" Tab,并把页面滚到 Tab 栏刚好贴在 AppBar 下方 + void gotoExchangeTab() { + final parent = parentLogic; + if (parent == null) return; + parent.tabCtr.animateTo(1); + // 等一帧让弹窗 dismiss 后 layout 稳定,再算位置 + WidgetsBinding.instance + .addPostFrameCallback((_) => _scrollTabKeyBelowAppBar()); + } + + /// 让 parent.tabKey 对应的 widget 滚到 AppBar 下沿 + void _scrollTabKeyBelowAppBar() { + final parent = parentLogic; + if (parent == null || !parent.outerCtr.hasClients) return; + final ctx = parent.tabKey.currentContext; + if (ctx == null) return; + final box = ctx.findRenderObject() as RenderBox?; + if (box == null || !box.attached) return; + // tabKey widget 当前在屏幕中的 Y 坐标 + final dy = box.localToGlobal(Offset.zero).dy; + // AppBar 总高度 = 状态栏 + 工具栏 + final appBarHeight = MediaQuery.of(ctx).padding.top + kToolbarHeight; + // 目标偏移 = 当前偏移 + (widget 屏幕 Y - AppBar 高度) + // 滚完后 widget 顶端正好贴 AppBar 底部 + final target = (parent.outerCtr.offset + dy - appBarHeight).clamp( + 0.0, + parent.outerCtr.position.maxScrollExtent, + ); + parent.outerCtr.animateTo( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + // ========== 私有方法 ========== + + /// 弹双倍奖励特效弹窗(视频循环播放) + /// 弹窗关闭后会自动暂停并释放 videoPlayerCtr,避免后台仍在播放 + void _showDoubleRewardDialog() async { + videoPlayerCtr?.dispose(); + final ctr = PlayerFactory.network( + '${Address.baseApiPath}/vid/h5/m3u8/$videoUrl?token=${Address.token}&c=${Address.cdnAddress}'); + videoPlayerCtr = ctr; + final dialogFuture = Get.dialog( + CongratulationsRewardDialog( + onExchange: gotoExchangeTab, + prizeList: prizeLists, + videoPlayerCtr: ctr, + ), + ); + try { + await ctr.initialize(); + await ctr.setLooping(true); + await ctr.play(); + } catch (e) { + // 弹窗已用此 ctr 构建,无法换 view 重试,仅内存切换;落本地由后续成功播放的播放器确认 + if (isDecoderError(e)) switchToPlatformView(); + } + // 等弹窗 dismiss(点击关闭/兑换/外部点击/back 都会触发) + await dialogFuture; + await ctr.pause(); + await ctr.dispose(); + // 防止与下次双倍奖励的新实例冲突 + if (identical(videoPlayerCtr, ctr)) videoPlayerCtr = null; + } + + /// 弹普通签到弹窗 + void _showNormalSignDialog() { + Get.dialog( + SignInDialog( + signInList: checkList, + continuousDays: continuousDays, + prizeList: prizeLists, + callback: gotoExchangeTab, + ), + ); + } +} + +/// 补签引导弹窗(私有给 Logic 用) +class _ResignGuideDialog extends StatelessWidget { + final VoidCallback onConfirm; + final VoidCallback onCancel; + const _ResignGuideDialog({required this.onConfirm, required this.onCancel}); + + @override + Widget build(BuildContext context) { + return Center( + child: Stack( + children: [ + Container( + width: 300, + margin: const EdgeInsets.only(left: 32, right: 32, bottom: 100), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)), + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + margin: const EdgeInsets.only(top: 30), + padding: const EdgeInsets.fromLTRB(24, 100, 24, 24), + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage('icon_resign_bg.webp'.mineImgPath), + fit: BoxFit.fill, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 260), + Row( + children: [ + Expanded( + child: _btn(onCancel, '离开', isPrimary: false)), + const SizedBox(width: 16), + Expanded( + child: _btn(onConfirm, '立即邀请', isPrimary: true)), + ], + ), + ], + ), + ), + ], + ), + ), + Positioned( + top: 0, + right: 10, + child: GestureDetector( + onTap: onCancel, + child: Image.asset('icon_resign_close.png'.mineImgPath, + width: 30, height: 30), + ), + ), + ], + ), + ); + } + + Widget _btn(VoidCallback onTap, String text, {required bool isPrimary}) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 50, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isPrimary ? null : Colors.white.withValues(alpha: 0.8), + gradient: isPrimary + ? const LinearGradient( + colors: [Color(0xFFF68804), Color(0xFFF68804)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ) + : null, + borderRadius: BorderRadius.circular(25), + border: isPrimary + ? null + : Border.all(color: const Color(0xFFB7B7B7), width: 1), + ), + child: Text( + text, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: isPrimary ? Colors.white : const Color(0xFF666666), + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/welfare/widget/recommend_app_page.dart b/lib/hj_page/mine/welfare/widget/recommend_app_page.dart new file mode 100644 index 0000000..89828e3 --- /dev/null +++ b/lib/hj_page/mine/welfare/widget/recommend_app_page.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/mine/happy/happy_model.dart'; +import 'package:hgdj/hj_model/splash/ads_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/banner/ads_banner_widget.dart'; + +import '../../../../tools_base/banner/ads_item.dart'; + +class RecommendAppPage extends StatelessWidget { + final AdTabConfig model; + + const RecommendAppPage(this.model, {super.key}); + @override + Widget build(BuildContext context) { + return CustomScrollView( + slivers: [ + if (model.bannerAds?.isNotEmpty ?? false) + SliverPadding( + padding: EdgeInsets.only(left: 16, right: 16, top: 12), + sliver: SliverToBoxAdapter( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AspectRatio( + aspectRatio: 343 / 143, + child: AdsBannerWidget( + model.bannerAds ?? [], + ), + ), + ), + ), + ), + if (model.hengAds != null) + SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: _buildNormalTitle(model.hengAds?.title ?? ''), + ), + SliverToBoxAdapter( + child: 10.sizeBoxH, + ), + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverGrid.builder( + itemCount: model.hengAds?.items?.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + crossAxisSpacing: 2, + mainAxisSpacing: 10, + childAspectRatio: 82 / 105, + ), + itemBuilder: (_, index) => + _buildVItem(model.hengAds!.items![index], index)), + ) + ], + ), + if (model.shuAds != null) + SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: _buildNormalTitle(model.shuAds?.title ?? ''), + ), + SliverPadding( + padding: EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList.separated( + itemCount: model.shuAds?.items?.length ?? 0, + separatorBuilder: (_, __) => + model.shuAds?.title == '热门推荐' ? 10.sizeBoxH : 1.line, + itemBuilder: (_, index) { + final info = model.shuAds!.items![index]; + if (model.shuAds?.title == '热门推荐') + return _buildHBItem(info, index); + return _buildHItem(info, index); + }, + ), + ) + ], + ), + ], + ); + } + + Widget _buildNormalTitle(String title) { + return Padding( + padding: EdgeInsets.fromLTRB(0, 20, 0, 0), + child: Row( + children: [ + 15.sizeBoxW, + Text( + title, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 16.0, + ), + ), + ], + ), + ); + } + + /// 竖版的广告 + _buildVItem(AdsInfoModel item, int index) { + return AdsItem( + adInfo: item, + clickType: 0, + ); + } + + _buildHItem(AdsInfoModel item, int index) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 12.sizeBoxH, + AdsItem( + adInfo: item, + showType: AdShowType.hor, + clickType: 0, + ), + 12.sizeBoxH, + ], + ); + } + + _buildHBItem(AdsInfoModel item, int index) { + return AdsItem( + adInfo: item, + showType: AdShowType.vImgText, + clickType: 0, + ); + } +} diff --git a/lib/hj_page/mine/welfare/widget/sign_dialog.dart b/lib/hj_page/mine/welfare/widget/sign_dialog.dart new file mode 100644 index 0000000..15766b9 --- /dev/null +++ b/lib/hj_page/mine/welfare/widget/sign_dialog.dart @@ -0,0 +1,287 @@ +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../../../tools_base/widget/net_image_widget.dart'; +import 'checkin_model.dart'; + +class SignInDialog extends StatelessWidget { + final List? signInList; // 签到日历数据 + final int? continuousDays; // 连续签到天数 + final Function? callback; + final List? prizeList; + + SignInDialog( + {super.key, + this.signInList, + this.continuousDays, + this.callback, + this.prizeList}); + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.symmetric(horizontal: 30), + child: Stack( + children: [ + Container( + padding: EdgeInsets.only(left: 12, right: 12, top: 12, bottom: 40), + margin: EdgeInsets.only(top: 50), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Color(0xff0F0F0F), + border: Border.all(width: 1, color: Color(0xffFFE7B4))), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 描述 + EasyRichText( + '已连续签到 $continuousDays 天,第7天有惊喜好礼相送!', + defaultStyle: TextStyle(color: Colors.white, fontSize: 14), + patternList: [ + EasyRichTextPattern( + stringBeforeTarget: '已连续签到 ', + targetString: '$continuousDays', + style: TextStyle(color: Color(0xffFFDA0B), fontSize: 14), + ), + EasyRichTextPattern( + targetString: '第7天有惊喜好礼相送!', + style: TextStyle(color: Color(0xffFFDA0B), fontSize: 14), + ), + ], + ), + + 12.sizeBoxH, + Divider(height: 1, color: Colors.white.withValues(alpha: .1)), + 12.sizeBoxH, + // Grid 奖励 + _buildGrid(), + 13.sizeBoxH, + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Color(0xffFFD900).withValues(alpha: .1), + borderRadius: BorderRadius.circular(12)), + child: Column( + children: [ + Text( + "- 积分可兑换以下豪礼 -", + style: TextStyle( + color: Color(0xffFFD900), + fontSize: 12, + ), + ), + SizedBox(height: 12), + // 奖励列表 + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.start, + children: prizeList + ?.map((e) => _buildRewardItem( + e.name ?? "", e.icon ?? "")) + .toList() ?? + [], + ), + ], + ), + ), + SizedBox(height: 24), + // 按钮 + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + Get.back(); + callback?.call(); + }, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: const Color(0xFFFFD700), width: 2), + ), + child: Text( + "兑换好礼", + style: TextStyle( + color: const Color(0xFFFFD700), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: () { + Get.back(); + }, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Color(0xffFFD900), + borderRadius: BorderRadius.circular(24), + ), + child: Text( + "继续领积分", + style: TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + Positioned( + left: 0, + right: 0, + child: Image.asset('sign_take_top.webp'.mineImgPath, + height: 30, fit: BoxFit.contain)), + ], + ), + ); + } + + // ========================= + // Grid + // ========================= + Widget _buildGrid() { + final list = signInList?.take(6).toList() ?? []; + return Container( + height: 68, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(20)), + child: ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + physics: const ScrollPhysics(), + itemCount: list.length, + padding: EdgeInsets.zero, + itemBuilder: (_, i) { + final item = list[i]; + return _buildItem(item, i); + }, + ), + ); + } + + // ========================= + // 单个格子 + // ========================= + Widget _buildItem(CheckinPrize item, int index) { + final isSigned = (continuousDays ?? 0) > index; + bool active = continuousDays == index; + return Container( + margin: EdgeInsets.only(right: 7), + width: 42, + height: 68, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.white.withValues(alpha: .1), + border: active + ? Border.all(color: const Color(0xffFFDA0B), width: 0.5) + : null, + ), + // padding: EdgeInsets.all(5), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + (item.image != null && item.image!.isNotEmpty) + ? ColorFiltered( + colorFilter: isSigned == true + ? ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]) + : ColorFilter.mode( + Colors.transparent, BlendMode.multiply), + child: NetworkImageLoader( + imageUrl: item.image!, + width: 34, + height: 34, + fit: BoxFit.contain)) + : Image.asset( + isSigned == true + ? 'checked_in_coin.webp'.mineImgPath + : 'check_in_coin.webp'.mineImgPath, + width: 34, + height: 34, + fit: BoxFit.cover), + Text( + item.prizeName ?? '', + style: TextStyle( + color: isSigned == true ? Colors.white : Color(0xffFFD900), + fontSize: 10), + ), + ], + ), + ); + } + + Widget _buildRewardItem(String title, String iconPath) { + return Column( + children: [ + Container( + width: 53, + height: 53, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.white.withValues(alpha: .2), width: 0.5)), + padding: EdgeInsets.all(6), + child: NetworkImageLoader(imageUrl: iconPath, fit: BoxFit.contain), + ), + SizedBox(height: 6), + SizedBox( + width: 60, + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 10, + ), + ), + ), + ], + ); + } +} diff --git a/lib/hj_page/mine/welfare/widget/sign_in_model.dart b/lib/hj_page/mine/welfare/widget/sign_in_model.dart new file mode 100644 index 0000000..d89aa6f --- /dev/null +++ b/lib/hj_page/mine/welfare/widget/sign_in_model.dart @@ -0,0 +1,271 @@ +class SignInModel { + final int? consecutiveSignDays; + final List? list; + final List? reSign; + final int? remainDay; + final int? reSignPrice; + final int? today; + final int? value; + final bool? isSign; + // final Prize? signPrize; + + SignInModel({ + this.consecutiveSignDays, + this.list, + this.remainDay, + this.reSignPrice, + this.value, + this.reSign, + this.isSign, + this.today, + // this.signPrize, + }); + + factory SignInModel.fromJson(Map json) => SignInModel( + consecutiveSignDays: json["consecutiveSignDays"], + list: + json["list"] == null ? [] : List.from(json["list"]!.map((x) => SignInListItem.fromJson(x))), + reSign: json["reSign"] == null ? [] : List.from(json["reSign"]!.map((x) => x)), + remainDay: json["remainDay"], + reSignPrice: json["reSignPrice"], + value: json["value"], + isSign: json["isSign"], + today: json["today"], + // signPrize: Prize.fromJson(json['signPrize']), + ); + + Map toJson() => { + "consecutiveSignDays": consecutiveSignDays, + "list": list == null ? [] : List.from(list!.map((x) => x.toJson())), + "reSign": reSign, + "reSignPrice": reSignPrice, + "remainDay": remainDay, + "value": value, + "isSign": isSign, + "today": today, + // "signPrize": signPrize?.toJson(), + }; +} + +class SignInListItem { + final int? boonType; + final String? desc; + final int? finishCondition; + final int? finishValue; + final String? id; + final List? prizes; + int? status; //任务状态 1:未完成 2:已完成 3:已领取 4:补签 + final String? title; + final String? name; + final String? prizeId; + bool? isToday; + + SignInListItem({ + this.boonType, + this.desc, + this.finishCondition, + this.finishValue, + this.id, + this.prizes, + this.status, + this.title, + this.prizeId, + this.isToday, + this.name, + }); + + factory SignInListItem.fromJson(Map json) => SignInListItem( + boonType: json["boonType"], + desc: json["desc"], + finishCondition: json["finishCondition"], + finishValue: json["finishValue"], + id: json["id"], + prizes: json["prizes"] == null ? [] : List.from(json["prizes"]!.map((x) => Prize.fromJson(x))), + status: json["status"], + isToday: json["isToday"], + title: json["title"], + prizeId: json["prizeId"], + name: json["name"], + ); + + Map toJson() => { + "boonType": boonType, + "desc": desc, + "finishCondition": finishCondition, + "finishValue": finishValue, + "id": id, + "prizes": prizes == null ? [] : List.from(prizes!.map((x) => x.toJson())), + "status": status, + "title": title, + "prizeId": prizeId, + "name": name, + }; +} + +class Prize { + final String? activityId; + final int? count; + final String? createTimt; + final String? desc; + final String? id; + final String? image; + final int? level; + final String? name; + final int? price; + final int? sort; + final bool? status; + final int? type; + final String? updateTime; + final int? validityTime; + final int? value; + final String? vipCardId; + final String? weights; + + Prize({ + this.activityId, + this.count, + this.createTimt, + this.desc, + this.id, + this.image, + this.level, + this.name, + this.price, + this.sort, + this.status, + this.type, + this.updateTime, + this.validityTime, + this.value, + this.vipCardId, + this.weights, + }); + + factory Prize.fromJson(Map json) => Prize( + activityId: json["activityId"], + count: json["count"], + createTimt: json["createTimt"], + desc: json["desc"], + id: json["id"], + image: json["image"], + level: json["level"], + name: json["name"], + price: json["price"], + sort: json["sort"], + status: json["status"], + type: json["type"], + updateTime: json["updateTime"], + validityTime: json["validityTime"], + value: json["value"], + vipCardId: json["vipCardId"], + weights: json["weights"], + ); + + Map toJson() => { + "activityId": activityId, + "count": count, + "createTimt": createTimt, + "desc": desc, + "id": id, + "image": image, + "level": level, + "name": name, + "price": price, + "sort": sort, + "status": status, + "type": type, + "updateTime": updateTime, + "validityTime": validityTime, + "value": value, + "vipCardId": vipCardId, + "weights": weights, + }; +} + +class ExtraSignRewardItem { + final String? activityId; + final int? count; + final String? createTimt; + final String? desc; + final bool? extraPrizeStatus; // 是否已领取额外奖励 + final int? finishCondition; // 完成条件(需要签到的天数) + final String? id; + final String? image; + final int? level; + final String? name; + final int? price; + final int? sort; + final bool? status; + final int? type; + final String? updateTime; + final int? validityTime; + final int? value; + final String? vipCardId; + final String? weights; + + ExtraSignRewardItem({ + this.activityId, + this.count, + this.createTimt, + this.desc, + this.extraPrizeStatus, + this.finishCondition, + this.id, + this.image, + this.level, + this.name, + this.price, + this.sort, + this.status, + this.type, + this.updateTime, + this.validityTime, + this.value, + this.vipCardId, + this.weights, + }); + + factory ExtraSignRewardItem.fromJson(Map json) => ExtraSignRewardItem( + activityId: json["activityId"], + count: json["count"], + createTimt: json["createTimt"], + desc: json["desc"], + extraPrizeStatus: json["extraPrizeStatus"], + finishCondition: json["finishCondition"], + id: json["id"], + image: json["image"], + level: json["level"], + name: json["name"], + price: json["price"], + sort: json["sort"], + status: json["status"], + type: json["type"], + updateTime: json["updateTime"], + validityTime: json["validityTime"], + value: json["value"], + vipCardId: json["vipCardId"], + weights: json["weights"], + ); + + Map toJson() => { + "activityId": activityId, + "count": count, + "createTimt": createTimt, + "desc": desc, + "extraPrizeStatus": extraPrizeStatus, + "finishCondition": finishCondition, + "id": id, + "image": image, + "level": level, + "name": name, + "price": price, + "sort": sort, + "status": status, + "type": type, + "updateTime": updateTime, + "validityTime": validityTime, + "value": value, + "vipCardId": vipCardId, + "weights": weights, + }; +} diff --git a/lib/hj_page/mine/welfare/widget/task_widgets.dart b/lib/hj_page/mine/welfare/widget/task_widgets.dart new file mode 100644 index 0000000..af9d43b --- /dev/null +++ b/lib/hj_page/mine/welfare/widget/task_widgets.dart @@ -0,0 +1,1175 @@ +import 'dart:async'; + +import 'package:easy_rich_text/easy_rich_text.dart'; +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/home/module_detail_model.dart'; +import 'package:hgdj/hj_model/list_base_model.dart'; +import 'package:hgdj/hj_model/mine/task_center_data.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart'; +import 'package:hgdj/hj_page/mine/welfare/widget/checkin_model.dart'; +import 'package:hgdj/hj_page/mine/welfare/widget/custom_progressbar.dart'; +import 'package:hgdj/hj_page/mine/welfare/widget/mine_sign_logic.dart'; +import 'package:hgdj/hj_page/mine/widgets/gradient_text.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../home/home_cell_style/video_simple_cell.dart'; +import '../../mine_vip/mine_charge_coin_page.dart'; +import '../../mine_vip/mine_charge_vip_page.dart'; +import '../more_free_video_page.dart'; +import '../sign_daily_logic.dart'; + +// ============================================================ +// 灰度滤镜常量:用于已签到/已领取的奖励图标置灰 +// ============================================================ +final ColorFilter _kGrayFilter = ColorFilter.matrix([ + 0.2126, 0.7152, 0.0722, 0, 0, // + 0.2126, 0.7152, 0.0722, 0, 0, // + 0.2126, 0.7152, 0.0722, 0, 0, // + 0, 0, 0, 1, 0, // +]); +const ColorFilter _kNoFilter = + ColorFilter.mode(Colors.transparent, BlendMode.multiply); + +// ============================================================ +// 1. TaskVIPVideosView:福利页 - VIP/特价视频区 +// ============================================================ +class TaskVIPVideosView extends StatefulWidget { + const TaskVIPVideosView({super.key}); + + @override + State createState() => _TaskVIPVideosViewState(); +} + +class _TaskVIPVideosViewState extends State { + final dataSource = >>[]; + final rawData = []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadData()); + } + + Future _loadData() async { + final res = await VidService.fetchFreeSource(); + if (!mounted) return; + rawData.addAll(res ?? []); + for (final element in res ?? []) { + dataSource.add(VidService.fetchFreeSourceList(element.id ?? '', + size: 4, sortType: 0)); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + if (dataSource.isEmpty) return const SizedBox.shrink(); + return ListView.separated( + separatorBuilder: (_, __) => 12.sizeBoxH, + itemCount: dataSource.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 18.w), + itemBuilder: (_, index) => + FreeAreaVideoGridView(dataSource[index], model: rawData[index]), + ); + } +} + +/// 单个特价视频区块(2×2 网格 + 背景图 + 跳转更多) +class FreeAreaVideoGridView extends StatelessWidget { + final Future?> dataEntry; + final WareDiscountAreaData? model; + + const FreeAreaVideoGridView(this.dataEntry, {super.key, this.model}); + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: dataEntry, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox(height: 340, child: LoadingCenterWidget()); + } + if (snapshot.hasError || + snapshot.data == null || + (snapshot.data?.list?.isEmpty ?? true)) { + return const SizedBox.shrink(); + } + return SizedBox( + height: 342, + child: Stack( + children: [ + Image.asset('area_discount_bg.webp'.mineImgPath, + height: double.infinity, fit: BoxFit.fill), + Positioned.fill( + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 20, horizontal: 8), + child: Column( + children: [ + _buildHeader(), + 12.sizeBoxH, + Expanded(child: _buildGrid(snapshot.data!.list!)), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } + + /// 标题行:分类名 + "更多" + Widget _buildHeader() { + return Row( + children: [ + Text( + model?.title ?? '', + style: const TextStyle( + color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500), + ), + const Spacer(), + GestureDetector( + onTap: () => + Get.to(MoreFreeVideoPage(model?.title ?? '', model?.id ?? '')), + child: const Row( + children: [ + Text('更多', + style: TextStyle(color: Color(0xff999999), fontSize: 12)), + Icon(Icons.arrow_forward_ios_rounded, + size: 10, color: Color(0xff999999)), + ], + ), + ), + ], + ); + } + + /// 2×2 视频网格 + Widget _buildGrid(List list) { + return LayoutBuilder( + builder: (_, constraints) { + final width = (constraints.maxWidth - 7) / 2; + final height = (constraints.maxHeight - 12) / 2; + return Wrap( + spacing: 7, + runSpacing: 12, + children: list + .map((e) => SizedBox( + width: width, + height: height, + child: VideoSimpleCell(textLines: 1, videoModel: e), + )) + .toList(), + ); + }, + ); + } +} + +// ============================================================ +// 2. TaskCenterVIPGuideView:VIP 引导(顶部 VIP 卡 + 非 VIP 时显示特权介绍) +// ============================================================ +class TaskCenterVIPGuideView extends StatelessWidget { + TaskCenterVIPGuideView({super.key}); + + static const _privilegeIcons = [ + 'privilege_gold.webp', + 'privilege_ai.webp', + 'privilege_game.webp' + ]; + static const _privilegeTitles = ['至尊金币', 'AI科技', '成人游戏']; + static const _privilegeSubTs = ['价值100元', '价值500元', '价值1000元']; + + @override + Widget build(BuildContext context) { + return Consumer(builder: (_, store, __) { + final isVip = store.isVIP; + // 已是 VIP:只显示顶部卡 + if (isVip) return _buildVIPGuide(); + // 非 VIP:顶部卡 + 三大特权 + return Column( + children: [ + _buildVIPGuide(), + Transform.translate( + offset: const Offset(0, -14), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 15), + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage('vip_privilege_bg.webp'.mineImgPath), + fit: BoxFit.fill), + ), + child: Column( + children: [ + 18.sizeBoxH, + _buildSlogan(), + 12.sizeBoxH, + SizedBox(height: 155, child: _buildPrivilegeGrid()), + ], + ), + ), + ), + ], + ); + }); + } + + /// 年省 1800 元 富文本 slogan + Widget _buildSlogan() { + return Row( + children: [ + Image.asset('add_equal.png'.mineImgPath, width: 14), + 10.sizeBoxW, + EasyRichText( + '开通荣耀至尊卡会员预计年省1800元', + defaultStyle: const TextStyle( + color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + patternList: [ + EasyRichTextPattern( + targetString: '荣耀至尊卡|1800元', + style: const TextStyle( + color: Color(0xffE3C891), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ], + ), + ], + ); + } + + /// 3 列特权网格 + Widget _buildPrivilegeGrid() { + return GridView.builder( + shrinkWrap: false, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 111 / 149, + crossAxisSpacing: 12, + ), + itemCount: 3, + itemBuilder: (_, index) => Column( + children: [ + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage('privilege_bg.webp'.mineImgPath), + fit: BoxFit.fill), + ), + child: Column( + children: [ + 14.sizeBoxH, + Image.asset(_privilegeIcons[index].mineImgPath, height: 41), + 1.sizeBoxH, + Text( + _privilegeTitles[index], + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600), + ), + 3.sizeBoxH, + Text(_privilegeSubTs[index], + style: const TextStyle( + color: Color(0xff9FA19F), fontSize: 10)), + ], + ), + ), + ), + 6.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () => Get.to(MineChargeVipPage()), + child: Container( + width: 50, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xffFFD5A8), + borderRadius: BorderRadius.circular(18)), + child: const Text( + '待领取', + style: TextStyle( + color: Color(0xff692A1F), + fontSize: 10, + fontWeight: FontWeight.w500), + ), + ), + ), + ], + ), + ); + } + + /// 顶部 VIP 卡(VIP 状态 / 倒计时 / 跳转按钮) + Widget _buildVIPGuide() { + return Stack( + alignment: Alignment.centerLeft, + children: [ + Image.asset( + globalStore.isVIP + ? 'welfare_vip_bg.webp'.mineImgPath + : 'welfare_normal_bg.webp'.mineImgPath, + width: double.infinity, + fit: BoxFit.fitWidth, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + 22.sizeBoxW, + Expanded(child: _buildVipInfo()), + 14.w.sizeBoxW, + _buildVipBtn(), + 22.sizeBoxW, + ], + ), + ], + ); + } + + /// VIP 卡左侧文案(卡名 + 时间胶囊 / 倒计时) + Widget _buildVipInfo() { + return Consumer( + builder: (context, provider, child) => Container( + margin: const EdgeInsets.only(left: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'VIP限时特惠 畅享全场', + style: TextStyle( + color: Color(0xffF37952), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + 3.sizeBoxH, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + globalStore.isVIP + ? ((globalStore.meInfo?.vipName == null || + globalStore.meInfo?.vipName == '') + ? '至尊永久卡' + : globalStore.meInfo!.vipName!) + : '新人限时特惠', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500), + ), + 4.sizeBoxW, + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: .5), + borderRadius: BorderRadius.circular(32), + ), + child: ValueListenableBuilder( + valueListenable: provider.tick, + builder: (_, __, ___) { + final cd = provider.countdownConfig; + return Text( + globalStore.isVIP + ? globalStore.vipTip(hasVipName: false) + : cd != null + ? '${cd.discountHour}:${cd.discountMin}:${cd.discountSec}' + : '活动已结束', + style: const TextStyle( + color: Color(0xff999999), fontSize: 11), + ); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + /// VIP 卡右侧按钮(开通/升级/会员中心) + Widget _buildVipBtn() { + final isVip = globalStore.isVIP; + final isUpgrade = globalStore.meInfo?.isUpgrade == true; + final btnText = !isVip ? '开通会员' : (isUpgrade ? '补差价升级' : '会员中心'); + return GestureDetector( + onTap: () => Get.to(() => MineChargeVipPage()), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(90), + color: Color(isVip ? 0xffF68804 : 0xffFFD5A8), + ), + child: Row( + children: [ + Text( + btnText, + style: TextStyle( + color: Color(isVip ? 0xfffffffff : 0xff663D00), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + 2.w.sizeBoxW, + SvgPicture.asset( + isVip + ? 'vip_upgrade_arrow.svg'.mineImgPath + : 'vip_arrow.svg'.mineImgPath, + width: 12, + ), + ], + ), + ), + ); + } +} + +// ============================================================ +// 3. MineTaskView:任务列表 +// ============================================================ +class MineTaskView extends StatelessWidget { + final List tasks; + const MineTaskView(this.tasks, {super.key}); + + @override + Widget build(BuildContext context) { + if (tasks.isEmpty) return const SizedBox.shrink(); + return ListView.separated( + physics: const ClampingScrollPhysics(), + // 底部加上系统安全区,避免最后一个任务被导航条遮挡 + padding: + EdgeInsets.fromLTRB(16, 0, 16, 20 + Get.mediaQuery.padding.bottom), + itemCount: tasks.length, + separatorBuilder: (_, __) => 12.sizeBoxH, + itemBuilder: (_, index) => TaskItem(tasks[index]), + ); + } +} + +// ============================================================ +// 4. TaskItem:单个任务项(去完成 / 待领取 / 已领取) +// ============================================================ +class TaskItem extends StatefulWidget { + final DailyTask task; + const TaskItem(this.task, {super.key}); + + @override + State createState() => _TaskItemState(); +} + +class _TaskItemState extends State { + Timer? _countdownTimer; + + @override + void initState() { + super.initState(); + if (widget.task.countdownType == 1) { + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + } + + @override + void dispose() { + _countdownTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final task = widget.task; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 20.sizeBoxH, + NetworkImageLoader( + imageUrl: task.img ?? '', + width: 40, + height: 40, + borderRadius: 30), + 8.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + task.title ?? task.subTitle ?? '', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + if (task.prizesIntegral != null && + task.prizesIntegral != 0) ...[ + 4.sizeBoxW, + Text( + ' +${task.prizesIntegral}积分', + style: const TextStyle( + color: Color(0xffF54570), + fontWeight: FontWeight.w400, + fontSize: 12), + ), + ], + if (task.countdownType == 1) ...[ + 8.sizeBoxW, + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(26), + color: const Color(0xffFFA800), + ), + child: const Text('限时', + style: + TextStyle(color: Colors.white, fontSize: 10)), + ), + ], + ], + ), + _buildTaskDescText(), + ], + ), + ), + 8.sizeBoxH, + if (!_isCountdownEnded()) _buildTaskBtn(task), + ], + ), + ); + } + + Widget _buildTaskDescText() { + final task = widget.task; + // 描述优先取 desc,为空再退回 subTitle + final text = task.countdownType == 1 + ? _getCountdownDesc() + : (task.desc?.isNotEmpty == true ? task.desc! : (task.subTitle ?? '')); + return Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xffaaaaaa), fontSize: 12), + ); + } + + bool _isCountdownEnded() { + final task = widget.task; + if (task.countdownType != 1) return false; + if (task.endAt == null || task.endAt!.isEmpty) return false; + try { + final endTime = DateTime.parse(task.endAt!).toLocal(); + return !DateTime.now().isBefore(endTime); + } catch (_) { + return false; + } + } + + String _getCountdownDesc() { + final task = widget.task; + try { + final now = DateTime.now(); + if (task.startAt != null && task.startAt!.isNotEmpty) { + final startTime = DateTime.parse(task.startAt!).toLocal(); + if (now.isBefore(startTime)) { + return '下一场倒计时开启${_formatCountdown(startTime.difference(now))}'; + } + } + if (task.endAt != null && task.endAt!.isNotEmpty) { + final endTime = DateTime.parse(task.endAt!).toLocal(); + if (now.isBefore(endTime)) { + return '进行中 倒计时${_formatCountdown(endTime.difference(now))}'; + } + } + } catch (_) {} + return task.desc ?? ''; + } + + String _formatCountdown(Duration duration) => + DateTimeUtil.formatHMS(duration.inSeconds, alwaysHour: true); + + String _getTaskStatus(int? status) { + if (widget.task.countdownType == 1 && + widget.task.startAt != null && + widget.task.startAt!.isNotEmpty) { + try { + final startTime = DateTime.parse(widget.task.startAt!).toLocal(); + if (DateTime.now().isBefore(startTime)) { + return '待开启'; + } + return '领红包'; + } catch (_) {} + } + + switch (status) { + case 1: + return '待领取'; + case 2: + return '已领取'; + default: + return '去完成'; + } + } + + /// 任务按钮:根据 status / 红包雨时段 显示文案与样式 + Widget _buildTaskBtn(DailyTask task) { + final btnText = _getTaskStatus(task.status); + final isClaimStyle = btnText == '待开启' || btnText == '待领取'; + final isDone = btnText == '已领取' || task.status == 2; + final bgColor = isClaimStyle + ? const Color(0xffFFD5A8) + : isDone + ? const Color(0xFF525252) + : const Color(0xffF68804); + final textColor = isClaimStyle ? const Color(0xff692A1F) : Colors.white; + final isSmall = isClaimStyle || isDone; + return InkWell( + enableFeedback: false, + onTap: () => _onBtnTap(task, btnText), + child: Container( + width: 70, + height: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), color: bgColor), + child: Center( + child: Text( + btnText, + style: TextStyle( + color: textColor, + fontWeight: FontWeight.w500, + fontSize: isSmall ? 12 : 14), + ), + ), + ), + ); + } + + /// 任务按钮点击:红包雨 → 跳转 link;未完成 → 去完成;待领取 → 领奖 + void _onBtnTap(DailyTask task, String btnText) { + if (btnText == '领红包' || btnText == '待开启') { + pushToPageByLink(task.link); + return; + } + switch (task.status) { + case 0: + if (task.title == '累计充值') { + Get.to(MineChargeCoinPage()); + return; + } + pushToPageByLink(task.link); + _doTask(task); + break; + case 1: + _receiveTask(task.id, task.doType); + break; + } + } + + /// 完成任务(标记 doType 为 1/2 的任务) + Future _doTask(DailyTask task) async { + final needDotask = (task.doType == 1 && task.type == 0) || + (task.doType == 2 && task.type == 2); + if (!needDotask) return; + final res = await CommonService.doTask(task.id ?? '', task.doType); + if (res) { + widget.task.status = 1; + if (mounted) setState(() {}); + } + } + + /// 领取任务奖励 + Future _receiveTask(String? taskId, int? type) async { + final res = await MineService.receiveIntegral(taskId, type); + if (res) { + widget.task.status = 2; + globalStore.refreshWallet(); + if (mounted) setState(() {}); + } + } +} + +// ============================================================ +// 5. MineSignView:每日签到主体 +// StatelessWidget + GetBuilder +// ============================================================ +class MineSignView extends StatelessWidget { + final SignDailyPageLogic? logic; + const MineSignView({super.key, this.logic}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: MineSignLogic(parentLogic: logic), + global: false, + builder: (logic) => Column( + children: [ + 18.sizeBoxH, + Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 15.w), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(12.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GradientText( + '每日签到领福利', + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w500), + gradient: const LinearGradient( + colors: [Color(0xffFFE580), Color(0xffFACC15)]), + ), + 4.sizeBoxH, + if (logic.continuousDays <= 7) + CustomProgressBar(progress: logic.continuousDays / 7), + 6.sizeBoxH, + _buildContinuousText(logic.continuousDays), + 12.sizeBoxH, + _buildSignInGrid(logic), + 18.sizeBoxH, + _buildSignInButton(logic), + if (!globalStore.isVIP) ...[ + 10.sizeBoxH, + _buildVipRechargeHint(), + ], + ], + ), + ), + ], + ), + ); + } + + /// "已连续签到 X 天" 富文本 + Widget _buildContinuousText(int continuousDays) { + return EasyRichText( + '已连续签到 $continuousDays 天 神秘大礼在等你', + defaultStyle: + TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: .55)), + patternList: [ + EasyRichTextPattern( + targetString: '$continuousDays', + style: const TextStyle(fontSize: 14, color: Color(0xFFF52C56)), + ), + ], + ); + } + + /// 签到三排联动滚动(天数标题 + 普通奖励 + 会员奖励) + Widget _buildSignInGrid(MineSignLogic logic) { + return Column( + children: [ + _SignInRow(_SignRowType.title, + controller: logic.titleScrollCtr, + list: logic.checkList ?? [], + logic: logic), + 12.sizeBoxH, + _SignInRow(_SignRowType.normal, + controller: logic.normalScrollCtr, + list: logic.checkList ?? [], + logic: logic), + 12.sizeBoxH, + _SignInRow(_SignRowType.vip, + controller: logic.vipScrollCtr, + list: logic.vipCheckList ?? [], + logic: logic), + ], + ); + } + + /// 签到按钮 + Widget _buildSignInButton(MineSignLogic logic) { + final isSigned = logic.isSigned; + return GestureDetector( + onTap: logic.doSignIn, + child: Center( + child: Container( + width: 209, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isSigned + ? Colors.white.withValues(alpha: .3) + : const Color(0xFFF68804), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + isSigned ? '今日已签到' : '会员打卡领双倍奖励', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isSigned ? Colors.white : Colors.black, + ), + ), + if (!isSigned) ...[ + 8.sizeBoxW, + Image.asset('icon_sign_click.webp'.mineImgPath, width: 21), + ], + ], + ), + ), + ), + ); + } + + /// 非 VIP 引导("立即解锁可领免费+豪奖") + Widget _buildVipRechargeHint() { + return GestureDetector( + onTap: () => Get.to(() => MineChargeVipPage()), + child: Container( + height: 18, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xFF171717), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + EasyRichText( + '立即解锁可领"免费+豪奖"双重奖励', + maxLines: 1, + overflow: TextOverflow.ellipsis, + defaultStyle: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: .88), + fontWeight: FontWeight.w400), + patternList: [ + EasyRichTextPattern( + targetString: r'"免费\+豪奖"双重奖励', + style: const TextStyle( + fontSize: 12, + color: Color(0xFFFFD900), + fontWeight: FontWeight.w500, + decoration: TextDecoration.underline, + decorationColor: Color(0xFFFFD900), + ), + ), + ], + ), + 8.sizeBoxW, + Container( + height: 18, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: const Color(0xFFFFD900), + borderRadius: BorderRadius.circular(12)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '开通会员', + style: TextStyle( + fontSize: 10, + color: Color(0xFF111111), + fontWeight: FontWeight.w500), + ), + 4.sizeBoxW, + Image.asset('ic_vip_enter.png'.mineImgPath, width: 12), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ============================================================ +// 签到列表行类型 +// ============================================================ +enum _SignRowType { + title, // 第 0 排:天数标题 + normal, // 第 1 排:普通奖励 + vip, // 第 2 排:会员奖励(带补领) +} + +/// 签到单行(横向滚动) +class _SignInRow extends StatelessWidget { + final _SignRowType type; + final ScrollController controller; + final List list; + final MineSignLogic? logic; + + const _SignInRow(this.type, + {required this.controller, required this.list, this.logic}); + + bool get _isTitle => type == _SignRowType.title; + bool get _isVip => type == _SignRowType.vip; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only(left: 10, top: _isTitle ? 0 : 5), + height: _isTitle ? 20 : 120, + decoration: BoxDecoration( + color: _isTitle + ? Colors.transparent + : _isVip + ? const Color(0xffFFDA0B).withValues(alpha: .08) + : Colors.white.withValues(alpha: .05), + borderRadius: BorderRadius.circular(_isTitle ? 0 : 18), + border: _isVip + ? const Border( + bottom: BorderSide(color: Color(0x99FFD900), width: 1)) + : null, + ), + child: Column( + children: [ + SizedBox( + height: _isTitle ? 20 : 70, + child: ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + itemCount: list.length, + itemBuilder: (_, i) => _buildCell(list[i], i), + ), + ), + if (!_isTitle) ...[ + 10.sizeBoxH, + Divider(height: 1, color: Colors.white.withValues(alpha: .1)), + 4.sizeBoxH, + Image.asset( + (type == _SignRowType.normal + ? 'no_vip_bottom.webp' + : 'vip_bottom.webp') + .mineImgPath, + height: 20, + ), + ], + ], + ), + ); + } + + Widget _buildCell(CheckinPrize prize, int i) { + if (_isTitle) { + // 顶部标签:已签到灰底,未签到金底;今日未签到时该格显示"今天" + final isTodayCell = + logic != null && !logic!.isSigned && i == logic!.continuousDays; + return Container( + width: 42, + margin: const EdgeInsets.only(right: 6), + alignment: Alignment.center, + decoration: BoxDecoration( + color: prize.isCheckedIn == true + ? Colors.white.withValues(alpha: .1) + : const Color(0xffFFD900), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + isTodayCell ? '今天' : '第${i + 1}天', + style: TextStyle( + color: prize.isCheckedIn == true + ? const Color(0xff999999) + : const Color(0xff62020E), + fontSize: 10, + ), + ), + ); + } + return Container( + width: 42, + margin: const EdgeInsets.only(right: 6), + alignment: Alignment.center, + child: _SignDayItem(prize, isVipRow: _isVip, logic: logic), + ); + } +} + +/// 单个签到日期格子(奖励图标 + 名称 + 锁 + 补领标识) +class _SignDayItem extends StatelessWidget { + final CheckinPrize model; + final bool isVipRow; + final MineSignLogic? logic; + + const _SignDayItem(this.model, {required this.isVipRow, this.logic}); + + /// 灰度滤镜规则:会员行需 isCheckedIn && isReceive 才置灰;普通行只看 isCheckedIn + ColorFilter get _filter { + if (isVipRow) { + return (model.isCheckedIn == true && model.isReceive == true) + ? _kGrayFilter + : (model.isExpired == true ? _kGrayFilter : _kNoFilter); + } + return model.isCheckedIn == true ? _kGrayFilter : _kNoFilter; + } + + /// 默认奖励图:根据状态切换金币 png + String get _defaultImg { + if (isVipRow) { + return (model.isCheckedIn == true && model.isReceive == true) + ? 'checked_in_coin.webp'.mineImgPath + : (model.isExpired == true + ? 'checked_in_coin.webp'.mineImgPath + : 'check_in_coin.webp'.mineImgPath); + } + return model.isCheckedIn == true + ? 'checked_in_coin.webp'.mineImgPath + : 'check_in_coin.webp'.mineImgPath; + } + + @override + Widget build(BuildContext context) { + final hasImg = model.image != null && model.image!.isNotEmpty; + return GestureDetector( + onTap: _onTap, + child: Stack( + children: [ + Container( + width: 42, + height: 68, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: const Color(0x0DFFFFFF), + border: isVipRow + ? const Border( + bottom: BorderSide(color: Color(0x99FFD900), width: 1)) + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 顶部灰色横条 + Container( + width: 13, + height: 2, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Colors.white.withValues(alpha: 0.1), + ), + ), + 4.sizeBoxH, + // 奖励图(接口图 / 默认金币图) + Stack( + alignment: Alignment.center, + children: [ + hasImg + ? ColorFiltered( + colorFilter: _filter, + child: NetworkImageLoader( + width: 34, + height: 34, + fit: BoxFit.contain, + imageUrl: model.image!), + ) + : Image.asset(_defaultImg, + width: 34, height: 34, fit: BoxFit.contain), + //覆盖在上面的过期图标 + if (model.isExpired == true) + Image.asset("ic_sign_expired.webp".mineImgPath, + width: 31, height: 31, fit: BoxFit.contain) + ], + ), + + 2.sizeBoxH, + Text( + model.prizeName ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + color: model.isCheckedIn == true + ? Colors.white + : const Color(0xFFFFD900), + ), + ), + ], + ), + ), + // 非 VIP 用户在 VIP 行显示锁 + if (!globalStore.isVIP && isVipRow) + Positioned( + left: 0, + top: 0, + child: Image.asset('ic_sign_lock.png'.mineImgPath, + width: 18, height: 18)), + // 可补领标识 + if (isVipRow && model.canClaim == true) + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: GestureDetector( + onTap: () => logic?.doCheckinClaimVip(), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xffD70000), + borderRadius: BorderRadius.circular(4)), + child: const Text( + '补领', + style: TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.w500), + ), + ), + ), + ), + ), + // 高 VIP 等级 + 普通行:显示 +1 标识 + if ((globalStore.meInfo?.vipLevel ?? 0) >= 1 && !isVipRow) + Positioned( + top: 6, + right: 2, + child: Image.asset('ic_add2.png'.mineImgPath, width: 11)), + ], + ), + ); + } + + void _onTap() { + if (!isVipRow) return; + if (model.canClaim == true) { + logic?.doCheckinClaimVip(); + } else if (!globalStore.isVIP) { + Get.to(() => MineChargeVipPage()); + } + } +} diff --git a/lib/hj_page/mine/widgets/exchange_vip_page.dart b/lib/hj_page/mine/widgets/exchange_vip_page.dart new file mode 100644 index 0000000..dd7f12b --- /dev/null +++ b/lib/hj_page/mine/widgets/exchange_vip_page.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; + +import '../../../hj_model/mine/credit_record_model.dart'; +import 'integral_record_logic.dart'; + +class ExchangeVipPage extends StatefulWidget { + const ExchangeVipPage({super.key}); + + @override + State createState() => _ExchangeVipPageState(); +} + +class _ExchangeVipPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('兑换记录')), + backgroundColor: Colors.black, + body: Stack( + children: [ + GetBuilder( + init: IntegralRecordLogic(), + builder: (_) => Padding( + padding: EdgeInsets.symmetric(vertical: 28, horizontal: 16), + child: Column( + children: [ + Expanded( + child: pullYsRefresh( + onInit: (controller) => + _.refreshController = controller, + onRefresh: (controller) => _.loadData(), + onLoading: (controller) => _.loadMoreData(), + child: CustomScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + ...[ + _buildHistoryTable(_), + ] + ], + ), + ), + ), + ], + )), + ) + ], + ), + ); + } + + Widget _buildHistoryTable(IntegralRecordLogic logic) { + if (logic.isLoadingHistoryData) { + return SliverToBoxAdapter(child: LoadingCenterWidget()); + } else if (logic.groupList.isEmpty) { + return SliverToBoxAdapter( + child: CErrorWidget(errorMsg: "暂无兑换记录"), + ); + } else { + return SliverList.separated( + itemBuilder: (context, index) { + return _buildListItem(logic.groupList[index]); + }, + itemCount: logic.groupList.length, + separatorBuilder: (context, index) { + return Divider( + height: 1, + color: Colors.black87.withValues(alpha: 0.1), + ); + }, + ); + } + } + + Widget _buildListItem(CreditRecordModel item) { + String desc = ""; + if (item.desc?.isNotEmpty == true) { + desc = item.desc ?? "-"; + List? list = desc.split("-"); + if (list.isNotEmpty == true && list.length > 1) { + desc = list[1]; + } + } + return SizedBox( + height: 44, + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.desc ?? '', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500), + ), + Text( + DateTimeUtil.utcTurnYear(item.createdAt), + style: TextStyle(color: Color(0xFF999999), fontSize: 12), + ) + ], + ), + Spacer(), + Text( + "${item.integral ?? 0}积分", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Color(0xFFF68804), + fontSize: 12, + fontWeight: FontWeight.w500), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/widgets/gradient_text.dart b/lib/hj_page/mine/widgets/gradient_text.dart new file mode 100644 index 0000000..ef533c9 --- /dev/null +++ b/lib/hj_page/mine/widgets/gradient_text.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +//渐变文字 +class GradientText extends StatelessWidget { + const GradientText( + this.text, {super.key, + required this.gradient, + this.style, + }); + + final String text; + final TextStyle? style; + final Gradient gradient; + + @override + Widget build(BuildContext context) { + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => gradient.createShader( + Rect.fromLTWH(0, 0, bounds.width, bounds.height), + ), + child: Text(text, style: style), + ); + } +} diff --git a/lib/hj_page/mine/widgets/integral_exchange_logic.dart b/lib/hj_page/mine/widgets/integral_exchange_logic.dart new file mode 100644 index 0000000..366d0b9 --- /dev/null +++ b/lib/hj_page/mine/widgets/integral_exchange_logic.dart @@ -0,0 +1,23 @@ +import 'package:get/get.dart'; + +import '../../../hj_model/mine/task_center_data.dart'; +import '../../../hj_utils/api_service/mine_service.dart'; +import '../../../tools_base/global_store/store.dart'; + +class IntegralExchangeLogic extends GetxController { + List? dataList; + + @override + void onReady() { + super.onReady(); + loadData(); + } + + loadData() async { + final res = await MineService.fetchExchangeList(); + dataList ??= []; + globalStore.refreshWallet(); + dataList?.addAll(res); + update(); + } +} diff --git a/lib/hj_page/mine/widgets/integral_exchange_page.dart b/lib/hj_page/mine/widgets/integral_exchange_page.dart new file mode 100644 index 0000000..a2880da --- /dev/null +++ b/lib/hj_page/mine/widgets/integral_exchange_page.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/mine/task_center_data.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; + +import '../../../routers/jump_router.dart'; +import '../../../tools_base/loading/loading_helper.dart'; +import '../../../tools_base/widget/common_dialog.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import 'integral_exchange_logic.dart'; + +class IntegralExchangePage extends StatelessWidget { + const IntegralExchangePage({super.key}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: IntegralExchangeLogic(), + global: false, + builder: (logic) { + if (logic.dataList == null) return LoadingCenterWidget(); + if (logic.dataList!.isEmpty) return CErrorWidget(); + + return GridView.builder( + padding: EdgeInsets.fromLTRB(16, 0, 16, 20), + physics: const ClampingScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10.w, + mainAxisSpacing: 14.h, + childAspectRatio: 174 / 111, + ), + itemCount: logic.dataList?.length ?? 0, + itemBuilder: (BuildContext context, int index) { + final model = logic.dataList?[index]; + return Container( + decoration: BoxDecoration( + color: Color(0xff303030), + borderRadius: BorderRadius.circular(9), + ), + padding: EdgeInsets.only( + left: 12.w, right: 12.w, top: 16.h, bottom: 9.h), + width: double.infinity, + height: double.infinity, + child: Column( + children: [ + Expanded( + child: ClipRRect( + borderRadius: + BorderRadius.vertical(top: Radius.circular(9)), + child: Stack( + alignment: Alignment.centerLeft, + children: [ + NetworkImageLoader( + imageUrl: model?.img ?? '', + borderRadius: 0, + width: double.infinity, + height: double.infinity, + ), + ], + ), + ), + ), + 10.sizeBoxH, + Row( + children: [ + Expanded( + child: Text( + '花费${model?.price ?? 0}积分', + style: TextStyle( + color: Color(0xFF989898), fontSize: 14.sp), + ), + ), + GestureDetector( + onTap: () => onExchange(model!), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 9.w, vertical: 3.h), + decoration: BoxDecoration( + color: Color(0xffF68804), + borderRadius: BorderRadius.circular(11)), + child: Text( + '立即兑换', + style: TextStyle( + color: Color(0xFF141414), fontSize: 12.sp), + ), + ), + ) + ], + ) + ], + ), + ); + }, + ); + }, + ); + } + + onExchange(IntegralExchangeModel model) async { + final hasIntegral = globalStore.wallet?.integral ?? 0; + if (hasIntegral < (model.price ?? 0)) { + showToast('积分不足,无法兑换~'); + return; + } + LoadingHelper.showLoading(); + final res = await MineService.integralExchange(model.id ?? ''); + LoadingHelper.dismissLoading(); + if (res) { + if (model.type == 5) { + Get.dialog(InputAddressAlert(model: model)); + } else { + showToast('兑换成功~'); + } + globalStore.refreshWallet(); + } + } +} + +class InputAddressAlert extends StatefulWidget { + // final Function()? confirmCallback; + final IntegralExchangeModel model; + const InputAddressAlert({super.key, required this.model}); + + @override + State createState() => _InputAddressAlertState(); +} + +class _InputAddressAlertState extends State { + final nameCtr = TextEditingController(); + final addressCtr = TextEditingController(); + final phoneCtr = TextEditingController(); + + @override + Widget build(BuildContext context) { + return CommonDialog( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '温馨提示', + style: TextStyle( + color: Color(0xffffffff), + fontSize: 18.sp, + fontWeight: FontWeight.w800), + ), + 20.h.sizeBoxH, + Divider( + height: 1, + color: Colors.white.withValues(alpha: .1), + ), + 20.h.sizeBoxH, + Container( + width: double.infinity, + height: 45.h, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 13.w), + child: Text( + "兑换成功!", + style: TextStyle(color: Color(0x8CFFFFFF), fontSize: 18), + textAlign: TextAlign.center, + ), + ), + 5.h.sizeBoxH, + Container( + width: double.infinity, + height: 45.h, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 13.w), + child: Text( + "请联系客服填写收货地址", + style: TextStyle(color: Color(0x8CFFFFFF), fontSize: 18), + textAlign: TextAlign.center, + ), + ), + 20.h.sizeBoxH, + Padding( + padding: EdgeInsets.symmetric(horizontal: 15.w), + child: GestureDetector( + onTap: () async { + pushToCustomService(); + }, + child: Container( + decoration: BoxDecoration( + color: Color(0xffF68804), + borderRadius: BorderRadius.circular(6)), + height: 48, + alignment: Alignment.center, + child: Text( + '联系客服', + style: TextStyle( + color: Colors.white, + fontSize: 18.sp, + fontWeight: FontWeight.w500), + ), + ), + ), + ) + ], + ), + ); + } +} diff --git a/lib/hj_page/mine/widgets/integral_record_logic.dart b/lib/hj_page/mine/widgets/integral_record_logic.dart new file mode 100644 index 0000000..b689c5c --- /dev/null +++ b/lib/hj_page/mine/widgets/integral_record_logic.dart @@ -0,0 +1,45 @@ +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../hj_model/mine/credit_record_model.dart'; + +class IntegralRecordLogic extends GetxController { + List groupList = []; + bool isLoadingHistoryData = true; + RefreshController? refreshController; //由 pullYsRefresh 的 onInit 注入,组件负责释放 + int currentPage = 1; + + IntegralRecordLogic(); + + @override + onReady() { + super.onReady(); + loadData(); + } + + void loadData({int page = 1}) async { + try { + final res = + await MineService.getCreditRecords(pageNumber: page, pageSize: 20); + if (res.isNotEmpty) { + if (page == 1) groupList.clear(); + currentPage = page; + groupList.addAll(res); + } + refreshController?.refreshCompleted(); + (res.length) < 20 + ? refreshController?.loadNoData() + : refreshController?.loadComplete(); + } catch (e) { + refreshController?.refreshCompleted(); + refreshController?.loadComplete(); + debugLog(e); + } + update(); + isLoadingHistoryData = false; + } + + loadMoreData() => loadData(page: currentPage + 1); +} diff --git a/lib/hj_page/mine/widgets/mine_info_widget.dart b/lib/hj_page/mine/widgets/mine_info_widget.dart new file mode 100644 index 0000000..dc5e165 --- /dev/null +++ b/lib/hj_page/mine/widgets/mine_info_widget.dart @@ -0,0 +1,305 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:provider/provider.dart'; + +import '../../home/widget/user_avatar.dart'; +import '../../home/widget/user_name_view.dart'; +import '../collec_history_buy/col_his_buy_page.dart'; +import '../home_mine_logic.dart'; +import '../mine_following/mine_following_view.dart'; +import '../mine_setting/mine_setting_page.dart'; +import '../mine_video_cache/video_cache_page.dart'; +import '../mine_vip/mine_charge_coin_page.dart'; +import '../mine_vip/mine_charge_vip_page.dart'; +import '../welfare/sign_daily_page.dart'; +import '../../ai/ai_girl/ai_h5_page.dart'; +import '../../../hj_utils/api_service/ai_service.dart'; +import '../../../tools_base/loading/loading_alert_widget.dart'; + +class MineInfoWidget extends StatelessWidget { + final MineMainLogic logic; + const MineInfoWidget({super.key, required this.logic}); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.only(left: 16, top: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + children: [ + UserAvatar( + size: 60, + showBorder: false, + model: Publisher() + ..portrait = logic.userInfo?.portrait + ..vipLevel = (logic.userInfo?.isVip == true) ? 1 : 0, + onTap: () { + Get.to(() => const MineSettingPage()); + }, + ), + 12.sizeBoxW, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Consumer( + builder: (context, store, child) { + final vipIcon = store.meInfo?.vipImageName ?? ''; + return Row( + children: [ + Flexible( + child: UserNameView( + name: logic.userInfo?.name ?? '', + fontSize: 20, + fontWeight: FontWeight.w500, + isVip: (logic.userInfo?.vipLevel ?? 0) > 0, + nameColor: Colors.white, + ), + ), + 8.sizeBoxW, + if (store.isVIP && vipIcon.isNotEmpty) + Image.asset(vipIcon, height: 20), + ], + ); + }, + ), + 5.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () { + Clipboard.setData(ClipboardData( + text: (logic.userInfo?.uid ?? 0).toString())); + showToast('复制成功'); + }, + child: Text( + "ID ${logic.userInfo?.uid ?? ''} 复制", + style: TextStyle( + color: Colors.white.withValues(alpha: .45), + fontSize: 12, + ), + ), + ), + ], + ), + ), + ], + ), + ), + GestureDetector( + onTap: () => Get.to(SignDailyPage()), + child: Stack( + children: [ + Container( + width: 60, + padding: EdgeInsets.symmetric(vertical: 5), + decoration: BoxDecoration( + color: Color(0xFFF68804), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), + bottomLeft: Radius.circular(15))), + margin: EdgeInsets.only(left: 20, top: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("签到", style: TextStyle(color: Colors.white)), + ], + )), + Container( + width: 30, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(vertical: 2, horizontal: 2), + decoration: BoxDecoration( + color: Color(0xFFF52C56), + borderRadius: BorderRadius.circular(15), + ), + margin: EdgeInsets.symmetric(horizontal: 16, vertical: 0), + child: Text( + "有奖", + style: TextStyle(color: Colors.white, fontSize: 10), + ), + ) + ], + ), + ) + ], + ), + ); + } +} + +//充值/代理赚钱/ai科技入口 +class MineTaskMenuWidget extends StatelessWidget { + static const double _overlap = 3; + + MineTaskMenuWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.symmetric(horizontal: 16), + width: double.infinity, + child: LayoutBuilder( + builder: (context, constraints) { + // 相邻卡片重叠 _overlap:单卡宽度 = (总宽 + 两段重叠) / 3 + final itemW = (constraints.maxWidth + _overlap * 2) / 3; + final items = [ + InkWell( + enableFeedback: false, + onTap: () => + Get.to(() => MineChargeVipPage(), preventDuplicates: false), + child: _buildItem("", 'mine_vip_enter.webp'.mineImgPath, 0), + ), + InkWell( + enableFeedback: false, + onTap: () { + globalStore.refreshWallet(); + Get.to(() => const MineChargeCoinPage()); + }, + child: _buildItem( + "余额:${(globalStore.wallet?.amount ?? 0) + (globalStore.wallet?.income ?? 0)}", + 'mine_gold_enter.webp'.mineImgPath, + 15, + ), + ), + InkWell( + enableFeedback: false, + onTap: _openAiGirlFriendDetail, + child: _buildItem("", 'mine_ai_enter.webp'.mineImgPath, 0), + ), + ]; + return Stack( + children: [ + // 用第一张图撑起 Stack 高度(等比例) + Opacity( + opacity: 0, + child: SizedBox(width: itemW, child: items.first), + ), + for (int i = 0; i < items.length; i++) + Positioned( + left: i * (itemW - _overlap), + width: itemW, + child: items[i], + ), + ], + ); + }, + ), + ); + } + + /// 拉 AI 女友链接后直接进详情 H5 + Future _openAiGirlFriendDetail() async { + LoadingAlertWidget.show(); + try { + final urlModel = await AIService.getMateUrl(); + LoadingAlertWidget.cancel(); + final url = urlModel?.url; + if (url == null || url.isEmpty) { + showToast('没有可以打开的AI女友,请联系客服~'); + return; + } + await Get.to(() => AiH5Page(webUrl: url)); + } catch (_) { + LoadingAlertWidget.cancel(); + showToast('打开失败,请稍后重试'); + } + } + + Widget _buildItem(String title, String imagePath, double left) { + return Stack( + children: [ + // 宽度铺满,高度按图片比例自适应,避免 fill 拉伸变形 + Image.asset( + imagePath, + width: double.infinity, + fit: BoxFit.fitWidth, + ), + if (title.isNotEmpty) + Positioned( + left: left, + bottom: 10, + child: Text( + title, + style: TextStyle( + fontSize: 10, + color: Colors.white.withValues(alpha: .55), + fontWeight: FontWeight.w400), + ), + ), + ], + ); + } +} + +class MineFunchtionGridView extends StatelessWidget { + MineFunchtionGridView({super.key}); + final functionLabels = ['我的收藏', '历史记录', '我的下载', '我的关注']; + final functionImgs = [ + 'mine_post.png', + 'mine_collect.png', + 'mine_follow.png', + 'mine_offical.png' + ]; + final functions = [ + () => ColHisBuyPage.to(PageType.collect), + () => ColHisBuyPage.to(PageType.history), + () => Get.to(() => VideoCachePage()), + () => Get.to(() => MineFollowingPage()), + ]; + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: Colors.white.withValues(alpha: .05), + ), + child: GridView.builder( + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: functionLabels.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + childAspectRatio: 1, + ), + itemBuilder: (context, index) { + String titleDesc = functionLabels[index]; + String imagePath = functionImgs[index]; + + return InkWell( + enableFeedback: false, + onTap: functions[index], + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset(imagePath.mineImgPath, width: 24), + 5.sizeBoxH, + Text( + titleDesc, + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/hj_page/mine/widgets/mine_share_qr_view.dart b/lib/hj_page/mine/widgets/mine_share_qr_view.dart new file mode 100644 index 0000000..d039c81 --- /dev/null +++ b/lib/hj_page/mine/widgets/mine_share_qr_view.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../../../config/address.dart'; +import '../../../config/config.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import '../../../hj_utils/image_util.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; + +class MineShareQRView extends StatefulWidget { + const MineShareQRView({super.key}); + + @override + State createState() => _MineShareQRViewState(); +} + +class _MineShareQRViewState extends State { + final boundaryKey = GlobalKey(); + int totalInvite = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + final record = await MineService.getBindRecord(1, 1); + totalInvite = record?.total ?? 0; + setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + RepaintBoundary( + key: boundaryKey, + child: Container( + width: screen.screenWidth - 60, + height: (screen.screenWidth - 60) * 1.6, + margin: EdgeInsets.symmetric(horizontal: 20, vertical: 20), + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage("share_bg.webp".mineImgPath), + fit: BoxFit.fill)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + children: [ + 30.sizeBoxH, + Consumer( + builder: (_, provider, __) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + border: + Border.all(color: Color(0x4DF68804), width: 3), + ), + child: NetworkImageLoader( + imageUrl: provider.meInfo?.portrait ?? '', + width: 82, + height: 82, + borderRadius: 50, + ), + ); + }, + ), + 10.sizeBoxH, + Text( + '我的邀请码', + style: TextStyle( + color: Colors.white.withValues(alpha: .9), + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + 10.sizeBoxH, + Text( + globalStore.meInfo?.promotionCode ?? "", + style: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + Column( + children: [ + Text("每邀请3人,送3天VIP", + style: + TextStyle(fontSize: 14, color: Color(0xFFF68804))), + 10.sizeBoxH, + Row( + children: [ + Spacer(), + Center( + child: Container( + padding: EdgeInsets.all(11), + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage("code_bg.webp".mineImgPath), + fit: BoxFit.fill, + ), + ), + child: Container( + padding: EdgeInsets.all(6), + color: Colors.white, + child: QrImageView( + data: globalStore.meInfo?.promoteURL ?? "", + version: QrVersions.auto, + size: 100, + backgroundColor: Colors.white, + ), + ), + ), + ), + Spacer(), + ], + ), + 10.sizeBoxH, + Text( + '提示*苹果手机请用相机扫码/安卓手机\n推荐UC浏览器扫码', + style: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + textAlign: TextAlign.center, + ), + 10.sizeBoxH, + Container( + height: 30, + padding: + EdgeInsets.symmetric(horizontal: 18, vertical: 3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0), + borderRadius: BorderRadius.circular(30), + ), + child: Text( + '${Config.appName} 官网地址 ${Address.groundUrl ?? ""}', + style: TextStyle( + color: Colors.white.withValues(alpha: .55), + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ), + 12.sizeBoxH, + ], + ) + ], + ), + ), + ), + 20.sizeBoxH, + InkWell( + enableFeedback: false, + onTap: () async { + final ok = await ImageUtil.saveWidgetToAlbum(boundaryKey); + showToast(ok ? "保存成功" : "保存失败,请重试"); + }, + child: Container( + height: 44, + width: 220, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Color(0xFFF68804)), + alignment: Alignment.center, + child: Text( + "保存图片", + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + fontSize: 16, + ), + ), + ), + ), + TextButton( + onPressed: () { + Clipboard.setData( + ClipboardData(text: globalStore.meInfo?.promoteURL ?? "")); + showToast('复制成功'); + }, + child: Text( + "复制链接", + style: TextStyle( + color: Color(0x8CFFFFFF), + decoration: TextDecoration.underline, // 添加下划线 + decorationColor: Color(0x8CFFFFFF), // 下划线颜色 + decorationThickness: 1.0, // 下划线粗细 + decorationStyle: TextDecorationStyle.solid, // 下划线样式 + ), + )) + ], + ); + } +} diff --git a/lib/hj_page/mine/widgets/stage_cell_widget.dart b/lib/hj_page/mine/widgets/stage_cell_widget.dart new file mode 100644 index 0000000..593d3ed --- /dev/null +++ b/lib/hj_page/mine/widgets/stage_cell_widget.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../hj_model/home/collection_model.dart'; + +class SimpleCollectionsItem extends StatelessWidget { + final CollectionModel videoModel; + const SimpleCollectionsItem(this.videoModel, {super.key}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + child: Container( + padding: EdgeInsets.only(bottom: 14), + child: NetworkImageLoader( + imageUrl: videoModel.cover ?? '', + height: 222, + borderRadius: 12, + ), + ), + ); + } +} diff --git a/lib/hj_page/mine/widgets/vip_entry_card.dart b/lib/hj_page/mine/widgets/vip_entry_card.dart new file mode 100644 index 0000000..e1858d2 --- /dev/null +++ b/lib/hj_page/mine/widgets/vip_entry_card.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/splash/domain_source_model.dart'; +import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import '../../../tools_base/global_store/store.dart'; +import '../mine_vip/mine_charge_vip_page.dart'; + +//会员卡入口 +class VipEntryCard extends StatelessWidget { + const VipEntryCard({super.key}); + + @override + Widget build(BuildContext context) { + // 分层配置刷新时重建(取最新 meTab) + return Consumer( + builder: (context, _, __) { + final config = MineMsgProvider().payTier?.config; + // 分层图优先:有 meTab 后端整图就用分层卡(含 tick 倒计时,点击带 vipCard) + if ((config?.meTab ?? '').isNotEmpty) return _layeredCard(config!); + // 否则走原三态(本地图 + 会员/新人/默认) + return _defaultCard(); + }, + ); + } + + Widget _defaultCard() { + return GestureDetector( + onTap: () => Get.to(() => MineChargeVipPage()), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Image.asset( + 'mine_vip_entry_bg.webp'.mineImgPath, + width: double.infinity, + fit: BoxFit.fill, + height: 64, + ), + Consumer2( + builder: (context, global, provider, child) { + final isUpgrade = + global.isVIP && global.meInfo?.isUpgrade == true; + if (global.isVIP) return _vipRow(global, isUpgrade: isUpgrade); + // 倒计时态/默认态的切换跟着 tick 走,过期当帧即收起,不必等分层配置刷新回来 + return ValueListenableBuilder( + valueListenable: provider.tick, + builder: (_, __, ___) { + final cd = provider.countdownConfig; + if (cd != null) return _newerRow(cd); + return _titleRow( + title: '开通会员 享专属特权', + subtitle: '解锁专属 畅享无限', + buttonText: '开通会员'); + }, + ); + }, + ), + ], + ), + ), + ); + } + + /// 支付分层会员卡:后端 meTab 整图 + lastDiscountTime 倒计时块,点击跳会员中心带 vipCard + /// 按 343x64 设计稿搭建,FittedBox 整体等比缩放,倒计时块坐标即设计像素 + Widget _layeredCard(PayTierConfig config) { + return GestureDetector( + onTap: () => Get.to(() => MineChargeVipPage(vipID: config.vipCard)), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: AspectRatio( + aspectRatio: 343 / 64, + child: FittedBox( + fit: BoxFit.fill, + child: SizedBox( + width: 343, + height: 64, + child: Stack( + children: [ + NetworkImageLoader( + imageUrl: config.meTab ?? "", + width: 343, + height: 64, + fit: BoxFit.fill, + borderRadius: 0), + // 倒计时块监听 tick 每秒刷新(过期自动收起) + ValueListenableBuilder( + valueListenable: MineMsgProvider().tick, + builder: (_, __, ___) => config.hasDiscountCountdown + ? _layeredCountdown(config) + : const SizedBox.shrink(), + ), + ], + ), + ), + ), + ), + ), + ); + } + + //分层倒计时块(深色款,坐标按后端 meTab 图设计,不对就调 left/top) + Widget _layeredCountdown(PayTierConfig config) { + return Positioned( + left: 69, + top: 37, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _layeredCell(config.discountHour), + _layeredColon(), + _layeredCell(config.discountMin), + _layeredColon(), + _layeredCell(config.discountSec), + ], + ), + ); + } + + Widget _layeredCell(String value) { + return Container( + width: 22, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xff1B1B1B), + borderRadius: BorderRadius.circular(3), + border: Border.all(color: const Color(0xffFFE381), width: 0.5), + ), + child: Text(value, + style: const TextStyle(color: Colors.white, fontSize: 12)), + ); + } + + Widget _layeredColon() => const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Text(":", style: TextStyle(color: Colors.white, fontSize: 12)), + ); + + Widget _newerRow(PayTierConfig config) { + return _cardRow( + left: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _goldText('新用户升级会员特惠', 16), + _countdown(config), + ]), + buttonText: '开通会员', + ); + } + + Widget _vipRow(GlobalStore global, {bool isUpgrade = false}) { + String vipTitle = global.meInfo?.vipName ?? ''; + if (vipTitle.isEmpty) { + final level = global.meInfo?.vipLevel ?? 0; + if (level == 1) vipTitle = '普通卡'; + if (level == 2) vipTitle = 'SVIP卡'; + if (level >= 3) vipTitle = '至尊永久卡'; + } + return _titleRow( + title: '$vipTitle会员', + subtitle: + '到期时间:${DateTimeUtil.utcTurnYear(global.meInfo?.vipExpireDate, char: ".")}', + buttonText: isUpgrade ? '补差价升级' : '会员中心', + onButtonTap: isUpgrade ? () => Get.to(() => MineChargeVipPage()) : null, + ); + } + + //通用布局:标题+副标题+按钮 + Widget _titleRow( + {required String title, + required String subtitle, + required String buttonText, + VoidCallback? onButtonTap}) { + return _cardRow( + left: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _goldText(title, 16), + Text(subtitle, + style: TextStyle(fontSize: 10, color: Color(0xFFE9E8E7))), + ]), + buttonText: buttonText, + onButtonTap: onButtonTap, + ); + } + + Widget _cardRow( + {required Widget left, + required String buttonText, + VoidCallback? onButtonTap}) { + return Container( + padding: EdgeInsets.only(left: 60, right: 16), + alignment: Alignment.centerLeft, + child: Row( + children: [ + Expanded(child: left), + _button(buttonText, onTap: onButtonTap), + ], + ), + ); + } + + Widget _button(String text, {VoidCallback? onTap}) { + return GestureDetector( + onTap: onTap, + behavior: + onTap != null ? HitTestBehavior.opaque : HitTestBehavior.translucent, + child: Container( + alignment: Alignment.center, + height: 26, + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + gradient: LinearGradient( + colors: [Color(0xffFBE9BC), Color(0xffFFFFFF), Color(0xffFBE9BC)], + tileMode: TileMode.mirror, + ), + ), + child: Text( + text, + style: TextStyle( + fontSize: 10, + color: Color(0xFF000000), + fontWeight: FontWeight.w600), + ), + ), + ); + } + + //渐变金色文字 + Widget _goldText(String text, double fontSize) { + return ShaderMask( + shaderCallback: (bounds) => LinearGradient( + colors: [Color(0xffFAE6D2), Color(0xffFFFFFF)], + ).createShader(bounds), + blendMode: BlendMode.srcIn, + child: Text(text, + style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600)), + ); + } + + //倒计时(外层 _defaultCard 已监听 tick,逐秒重建) + Widget _countdown(PayTierConfig config) { + return ShaderMask( + shaderCallback: (bounds) => LinearGradient( + colors: [Color(0xffE9E8E7), Color(0xffE9E8E7)], + ).createShader(bounds), + blendMode: BlendMode.srcIn, + child: Row(children: [ + Icon(Icons.access_time, size: 14), + _timeUnit(config.discountHour), + _timeColon(), + _timeUnit(config.discountMin), + _timeColon(), + _timeUnit(config.discountSec), + ]), + ); + } + + Widget _timeUnit(String val) => Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: + Text(val, style: TextStyle(fontSize: 12, color: Color(0xFFFF844D))), + ); + + Widget _timeColon() => + Text(":", style: TextStyle(fontSize: 10, color: Color(0xFFFF844D))); +} diff --git a/lib/hj_page/pre_sale/limit_time_provider.dart b/lib/hj_page/pre_sale/limit_time_provider.dart new file mode 100644 index 0000000..01da835 --- /dev/null +++ b/lib/hj_page/pre_sale/limit_time_provider.dart @@ -0,0 +1,100 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/splash/domain_source_model.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +import '../../../hj_utils/widget_util.dart'; + +final limitTimeProvider = LimitTimeProvider(); + +//限时活动 +class LimitTimeProvider extends ChangeNotifier { + static final LimitTimeProvider _instance = LimitTimeProvider._(); + + LimitTimeProvider._(); + + factory LimitTimeProvider() => _instance; + + Timer? _timer; + + BannerJump? bannerJump; //限时Banner活动 + + /// 活动倒计时,供 ValueListenableBuilder 局部刷新(不走 notifyListeners) + final deadline = ValueNotifier(''); + + /// 距截止时间的剩余秒数 + int get endSeconds => + bannerJump?.additionalProp?.endAt?.remainingSeconds() ?? -1; + + //是否展示活动 后台返回bannerjump && 结束时间未到 + bool get canShow => bannerJump != null && endSeconds > 0; + + //开启限时活动倒计时 + void refresh() { + if (canShow && _timer == null) _startTimer(); + notifyListeners(); + } + + // 定时器 + void _startTimer() { + _timer?.cancel(); // 防重入:先清掉已有定时器,杜绝多重泄漏 + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + // 每秒只更新 deadline,交给 ValueListenableBuilder 局部刷新,不触发全局 notifyListeners + deadline.value = bannerJump?.additionalProp?.endAt?.countdownText() ?? ''; + if (endSeconds < 0) { + timer.cancel(); + _timer = null; + notifyListeners(); // 到期时通知一次,隐藏限时活动入口(Consumer 依赖 canShow) + } + }); + } +} + +/// 限时活动横幅:整条点击跳活动页,右侧叠倒计时 +class LimitTimeBanner extends StatelessWidget { + const LimitTimeBanner({super.key}); + + @override + Widget build(BuildContext context) { + final prop = limitTimeProvider.bannerJump?.additionalProp; + return InkWell( + enableFeedback: false, + onTap: () => pushToPageByLink(prop?.url), + child: SizedBox( + height: 46, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: NetworkImageLoader( + imageUrl: prop?.banner ?? '', + borderRadius: 0, + fit: BoxFit.fill, + ), + ), + Positioned( + right: 12, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 18, vertical: 3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(99), + color: Colors.black.withValues(alpha: 0.2)), + child: ValueListenableBuilder( + valueListenable: limitTimeProvider.deadline, + builder: (context, value, child) => Text( + '$value ', + style: textStyle(12, Colors.white, FontWeight.w400), + ), + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/pre_sale/pre_sale_alert.dart b/lib/hj_page/pre_sale/pre_sale_alert.dart new file mode 100644 index 0000000..fa6283d --- /dev/null +++ b/lib/hj_page/pre_sale/pre_sale_alert.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_page.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import 'pre_sale_provider.dart'; + +//支付按钮 +class PreSaleActivityButton extends StatelessWidget { + final VoidCallback onTap; + + const PreSaleActivityButton({super.key, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + enableFeedback: false, + onTap: onTap, + child: NetworkImageLoader( + fit: BoxFit.fill, + imageUrl: presaleProvider.payBtnBg, + ).paddingSymmetric(horizontal: 6), + ); + } +} + +/// 首页预售弹窗:整张背景图就是弹窗内容,点图进预售页。 +/// 预定期 / 尾款期只差一张图,由 [PreSaleProvider.popBg] 按期次给,别再拆成两个类 +class PreSaleAlert extends StatelessWidget { + const PreSaleAlert({super.key}); + + @override + Widget build(BuildContext context) { + return Transform.translate( + offset: const Offset(0, -30), + child: GestureDetector( + onTap: () { + Get.back(); + Get.to(() => const PreSalePage()); + }, + child: Center( + child: Consumer( + builder: (_, provider, __) => NetworkImageLoader( + imageUrl: provider.popBg, + ), + ).paddingSymmetric(horizontal: 30), + ), + ), + ); + } +} diff --git a/lib/hj_page/pre_sale/pre_sale_entry.dart b/lib/hj_page/pre_sale/pre_sale_entry.dart new file mode 100644 index 0000000..305f410 --- /dev/null +++ b/lib/hj_page/pre_sale/pre_sale_entry.dart @@ -0,0 +1,179 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; + +import 'pre_sale_page.dart'; +import 'pre_sale_provider.dart'; + +// 首页 - 会员中心入口 +class PreSaleEntry extends StatefulWidget { + final ScrollController? scrollController; + + const PreSaleEntry({super.key, this.scrollController}); + + @override + State createState() => _PreSaleEntryState(); +} + +class _PreSaleEntryState extends State + with SingleTickerProviderStateMixin { + double rightPos = 6; + late final _controller = + AnimationController(vsync: this, duration: Duration(milliseconds: 900)); + late Animation _rotateAni; + late Animation _scaleAni; + + @override + void initState() { + _scaleAni = Tween(begin: 1, end: 1.02).animate( + CurvedAnimation(parent: _controller, curve: Interval(0, 0.8)), + ); + + _rotateAni = Tween(begin: 0, end: 2 * pi).animate( + CurvedAnimation(parent: _controller, curve: Interval(0.8, 1)), + ); + widget.scrollController?.addListener(() { + double offset = widget.scrollController?.offset ?? 0; + if (offset > (24 + 151 + 36 + 110 + 256)) { + if (rightPos == 6) { + rightPos = -65; + if (mounted) { + setState(() {}); + } + } + } else { + if (rightPos != 6) { + rightPos = 6; + if (mounted) { + setState(() {}); + } + } + } + }); + if (PreSaleProvider().canShowEnter) { + _controller.repeat(reverse: true); + } + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _openPreSale() { + // 不传来源:活动页内下单一律记 H5_ACTIVITY,不按入口区分 + Get.to(() => const PreSalePage()); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (ctx, provider, child) { + return provider.canShowEnter + ? GestureDetector( + onTap: _openPreSale, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) => Transform( + ///构建Matrix4 + transform: + Matrix4.rotationZ(sin(0.2 * sin(_rotateAni.value))), + alignment: Alignment.center, + child: NetworkImageLoader( + imageUrl: provider.enterBg, + width: 65 * _scaleAni.value, + ), + ), + ), + ) + : SizedBox.shrink(); + }, + ); + } +} + +//我的 - 活动页入口 +class PreSaleVipEntry extends StatefulWidget { + const PreSaleVipEntry({super.key}); + + @override + State createState() => _PreSaleVipEntryState(); +} + +class _PreSaleVipEntryState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Get.to(() => const PreSalePage()), + child: Consumer( + builder: (ctx, provider, child) { + return provider.canShowEnter + ? Stack( + children: [ + NetworkImageLoader( + imageUrl: provider.payBanner, + width: double.infinity, + fit: BoxFit.fill, + height: 76, + ), + if (provider.canPayBalance) + Positioned( + bottom: 11, + left: 84, + child: ValueListenableBuilder( + valueListenable: provider.deadline, + builder: (BuildContext context, String value, + Widget? child) { + return Text( + '剩余 $value', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w700), + ); + }, + ), + ) + ], + ) + : SizedBox.shrink(); + }, + ), + ); + } +} + +//活动抽奖 +class LuckyDrawButton extends StatelessWidget { + final EdgeInsetsGeometry? margin; + + const LuckyDrawButton({super.key, this.margin}); + + @override + Widget build(BuildContext context) { + if (presaleProvider.luckyDrawH5?.isNotEmpty != true) + return const SizedBox(); + final icon = presaleProvider.luckyDrawIcon; + return Container( + margin: margin, + child: InkWell( + enableFeedback: false, + onTap: presaleProvider.openLuckyDraw, + child: icon?.isNotEmpty == true + ? NetworkImageLoader(imageUrl: icon!, width: 60, height: 60) + : Image.asset('lucky_lottery.webp'.commonImgPath, width: 60), + ), + ); + } +} diff --git a/lib/hj_page/pre_sale/pre_sale_model.dart b/lib/hj_page/pre_sale/pre_sale_model.dart new file mode 100644 index 0000000..acbb754 --- /dev/null +++ b/lib/hj_page/pre_sale/pre_sale_model.dart @@ -0,0 +1,148 @@ +import 'package:hgdj/hj_utils/text_util.dart'; + +class PreSaleModel { + bool? hasNext; + List? list; + PreSaleDetailModel? detailModel; + PreSaleModel({this.hasNext, this.list, this.detailModel}); + + PreSaleModel.fromJson(Map map) { + hasNext = map['hasNext'] ?? false; + list = (map['list'] as List?) + ?.map((o) => PreSaleVideoModel.fromJson(o)) + .toList(); + detailModel = PreSaleDetailModel.fromJson(map['activityDetail']); + } + + Map toJson() => { + "hasNext": hasNext, + "list": list, + "PreSaleDetailModel": PreSaleDetailModel, + }; +} + +class PreSaleVideoModel { + PreSaleVideoModel({ + this.id, + this.vidId, + this.vidTitle, + this.cover, + this.sourceURL, + this.seriesCover, + this.coins, + this.detail, + }); + + PreSaleVideoModel.fromJson(Map json) { + id = json['id']; + vidId = json['vidId']; + vidTitle = json['vidTitle']; + sourceURL = json['sourceURL']; + cover = json['cover']; + seriesCover = parseStringList(json['seriesCover']); + coins = json['coins']; + detail = json['detail']; + } + String? id; + String? vidId; + String? vidTitle; + String? sourceURL; + String? cover; + List? seriesCover; + num? coins; + dynamic detail; + + Map toJson() { + final map = {}; + map['id'] = id; + map['vidId'] = vidId; + map['vidTitle'] = vidTitle; + map['sourceURL'] = sourceURL; + map['cover'] = cover; + map['seriesCover'] = seriesCover; + map['coins'] = coins; + map['detail'] = detail; + return map; + } +} + +class PreSaleDetailModel { + String? activityTime; //活动时间 + bool? balancePayment; //是否有尾款支付 + String? endTime; // 支付尾款结束时间 + String? startTime; // 支付尾款开始时间 + num? totalCount; //会员卡总数 + num? status; //预售订单状态 + num? balanceAmount; //尾款金额 + num? advanceAmount; //预付金额 + + PreSaleDetailModel({ + this.activityTime, + this.balancePayment, + this.endTime, + this.startTime, + this.totalCount, + this.status, + this.balanceAmount, + this.advanceAmount, + }); + + PreSaleDetailModel.fromJson(dynamic json) { + activityTime = json['activityTime']; + balancePayment = json['balancePayment']; + endTime = json['endTime']; + startTime = json['startTime']; + totalCount = json['totalCount']; + status = json['status']; + balanceAmount = json['balanceAmount']; + advanceAmount = json['advanceAmount']; + } + + Map toJson() { + final map = {}; + map['activityTime'] = activityTime; + map['balancePayment'] = balancePayment; + map['endTime'] = endTime; + map['startTime'] = startTime; + map['totalCount'] = totalCount; + map['status'] = status; + map['balanceAmount'] = balanceAmount; + map['advanceAmount'] = advanceAmount; + return map; + } +} + +//预售页面资源 +class AdvancePage { + String? banner; //个人中心 立即预定 + String? bannerWait; //个人中心 已预定,去支付尾款 + String? bannerProc; //个人中心 已预定,去支付尾款 + + String? buttonBackground; //支付按钮 立即预定 + String? buttonProcBackground; //支付按钮 已预定,去支付尾款 + String? buttonWaitBackground; //支付按钮 已预定,未到支付时间 + + String? enterBgProc; //入口按钮背景(付尾款) + String? enterBgWait; //入口按钮背景(未付款) + String? pageBackground; //大背景 + String? popBgProc; //弹窗背景(付尾款) + String? popBgWait; //弹窗背景(未付款) + String? pageVidBackground; //金币视频区的背景图 + + AdvancePage.fromJson(dynamic json) { + banner = json['banner']; + bannerWait = json['bannerWait']; + bannerProc = json['bannerProc']; + + buttonBackground = json['buttonBackground']; + buttonProcBackground = json['buttonProcBackground']; + buttonWaitBackground = json['buttonWaitBackground']; + + enterBgProc = json['enterBgProc']; + enterBgWait = json['enterBgWait']; + pageBackground = json['pageBackground']; + popBgProc = json['popBgProc']; + popBgWait = json['popBgWait']; + pageVidBackground = json['pageVidBackground']; + } +} diff --git a/lib/hj_page/pre_sale/pre_sale_page.dart b/lib/hj_page/pre_sale/pre_sale_page.dart new file mode 100644 index 0000000..e80e49e --- /dev/null +++ b/lib/hj_page/pre_sale/pre_sale_page.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; + +import '../../hj_utils/api_service/mine_service.dart'; +import '../../hj_utils/screen.dart'; +import '../../routers/jump_router.dart'; +import '../../tools_base/loading/loading_center_widget.dart'; +import '../mine/mine_vip/pay_order_source.dart'; +import '../mine/mine_vip/vip_support_model.dart'; +import 'pre_sale_alert.dart'; +import 'pre_sale_model.dart'; +import 'pre_sale_provider.dart'; + +class PreSalePage extends StatefulWidget { + const PreSalePage({super.key}); + + @override + State createState() => _PreSalePageState(); +} + +class _PreSalePageState extends State { + VipSupportModel? pageData; + bool isLoading = true; + VipProductListModel? selectProductM; + VipProductModel? selectVipItem; + + /// 仅供 /mine/topay 透传,不走卡皮埋点事件 + late final String _sessionId = const Uuid().v4(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + presaleProvider.refreshConfig(); + MineService.getVipProduct().then((value) { + pageData = value; + isLoading = false; + // 找到「预售卡」对应的商品组并选中第一项 + selectProductM = + pageData?.list?.firstWhereOrNull((e) => e.position == '预售卡'); + selectVipItem = selectProductM?.list?.firstOrNull; + if (mounted) setState(() {}); + }); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBodyBehindAppBar: true, + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (isLoading) return LoadingCenterWidget(); + if (pageData == null) return CErrorWidget(); + return Consumer( + builder: (_, __, ___) => Stack( + children: [ + SingleChildScrollView(child: _buildBgWithVideos()), + _buildPayButton(), + Positioned( + left: 16, + top: screen.paddingTop + 20, + child: InkWell( + enableFeedback: false, + onTap: () => Get.back(), + child: const Icon( + Icons.arrow_back_ios_sharp, + size: 18, + color: Colors.white, + ), + ), + ), + ], + ), + ); + } + + /// 整页背景图 + 视频网格 + Widget _buildBgWithVideos() { + return Stack( + children: [ + NetworkImageLoader( + imageUrl: presaleProvider.advancePage?.pageBackground ?? '', + fit: BoxFit.fill, + width: double.infinity, + borderRadius: 0, + height: 1970, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 20), + child: Column( + children: [ + const SizedBox(height: 1116), + _buildVideoBlock(), + ], + ), + ), + ], + ); + } + + /// 视频区背景 + 网格浮层 + Widget _buildVideoBlock() { + return Stack( + children: [ + NetworkImageLoader( + imageUrl: presaleProvider.advancePage?.pageVidBackground ?? ' ', + width: double.infinity, + height: 750, + fit: BoxFit.fill, + ), + Positioned( + top: 55, + left: 0, + right: 0, + bottom: 18, + child: _buildVideoGrid(), + ), + ], + ); + } + + Widget _buildVideoGrid() { + final list = presaleProvider.preSaleModel?.list ?? const []; + return GridView.builder( + padding: const EdgeInsets.only(left: 20, right: 20, top: 50), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: list.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, //横向数量 + crossAxisSpacing: 5, //间距 + mainAxisSpacing: 12, //行距 + childAspectRatio: 165 / 152, + ), + itemBuilder: (_, index) => _buildVideoItem(list[index]), + ); + } + + /// 底部支付按钮:首付状态或非升级款 → 用预售活动按钮;其余 → 自定义升级按钮 + Widget _buildPayButton() { + final useActivityBtn = + presaleProvider.isPayFirst || selectVipItem?.isUpgrade == false; + return Positioned( + bottom: 37, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 44), + height: 46, + child: useActivityBtn + ? PreSaleActivityButton(onTap: _onPay) + : _buildUpgradeButton(), + ), + ); + } + + Widget _buildUpgradeButton() { + return GestureDetector( + onTap: _onPay, + child: Container( + height: 46, + margin: const EdgeInsets.symmetric(horizontal: 20), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(46), + gradient: const LinearGradient( + colors: [Color(0xFF343434), Color(0xFF0E0E0E)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + border: Border.all(color: const Color(0xFFF8D38F), width: 1), + ), + child: Text( + payText, + style: const TextStyle( + color: Color(0xFFFFF0D3), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + String get payText { + // 预售的支付金额 + if (presaleProvider.canPayBalance) { + return '¥${presaleProvider.preSaleModel?.detailModel?.balanceAmount ?? 0}支付尾款'; + } + if (selectVipItem?.isUpgrade ?? false) { + return '¥${(selectVipItem?.advanceAmount ?? 0) ~/ 10}/立即升级'; + } + return '¥${presaleProvider.preSaleModel?.detailModel?.advanceAmount ?? 0}立即预订'; + } + + void _onPay() { + if (selectProductM == null && selectVipItem == null) { + return; + } + final active = pageData?.isExperimentActive == true; + presaleProvider.startPay( + selectVipItem!..isPreSale = true, + orderTrack: PayOrderTrackInfo( + // 支付发生在活动页,来源就记活动页,与从哪个入口(浮窗/弹窗/播放页/我的)进来无关 + sourcePage: PaySourcePage.h5Activity, + sessionId: _sessionId, + experimentId: active + ? (selectVipItem?.experimentId ?? pageData?.experimentId) + : null, + experimentVariant: + active ? (selectVipItem?.variant ?? pageData?.variant) : null, + ), + ); + } + + Widget _buildVideoItem(PreSaleVideoModel? videoModel) { + return InkWell( + enableFeedback: false, + onTap: () => pushToVideoPage(videoId: videoModel?.vidId), + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 10, + bottom: 0, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + Positioned.fill( + child: Column( + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3.5), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: + const Color(0xffD43824).withValues(alpha: .2), + blurRadius: 2), + ], + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: NetworkImageLoader( + imageUrl: videoModel?.cover ?? '', + width: null, + fit: BoxFit.cover, + ), + ), + ), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 15, vertical: 3), + child: Text( + "${videoModel?.vidTitle}", + style: const TextStyle( + color: Color(0xffDA0101), + fontWeight: FontWeight.w500, + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + Container( + height: 25, + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: + const Color(0xffD43824).withValues(alpha: .2), + blurRadius: 2), + ], + gradient: const LinearGradient( + colors: [Color(0xffFFF7E9), Color(0xffF9DC9A)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12.5), + ), + padding: const EdgeInsets.all(2), + child: Row( + children: [ + const SizedBox(width: 10), + const Text( + '免费', + style: TextStyle( + color: Color(0xffDA0101), + fontSize: 14, + fontWeight: FontWeight.w600), + ), + const SizedBox(width: 10), + Expanded( + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xffFFF3D9), Colors.white], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: BorderRadius.circular(12.5), + ), + alignment: Alignment.center, + child: Text( + "原价 ${videoModel?.coins}", + style: const TextStyle( + color: Color(0xff8B8B8B), + fontSize: 12.0, + decoration: TextDecoration.lineThrough, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 6.8), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/pre_sale/pre_sale_provider.dart b/lib/hj_page/pre_sale/pre_sale_provider.dart new file mode 100644 index 0000000..f07fbf4 --- /dev/null +++ b/lib/hj_page/pre_sale/pre_sale_provider.dart @@ -0,0 +1,270 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/config/address.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_page/web_page/h5_page.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/tools_base/net/net_manager.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../../hj_model/splash/domain_source_model.dart'; +import '../../hj_model/video_model.dart'; +import '../../tools_base/debug_log.dart'; +import '../mine/mine_vip/online_pay_page.dart'; +import '../mine/mine_vip/pay_order_source.dart'; +import '../mine/mine_vip/vip_support_model.dart'; +import 'pre_sale_alert.dart'; +import 'pre_sale_model.dart'; +import 'pre_sale_service.dart'; + +extension DateStringExt on String? { + /// 解析为本地时间。null / 空串 / 零值时间(0001-01-01) / 非法格式一律返回 null + DateTime? get _local { + final s = this; + if (s == null || s.isEmpty || s.contains('0001-01-01')) return null; + return DateTime.tryParse(s)?.toLocal(); + } + + /// 已过秒数:now − 该时间。> 0 表示该时间已过,< 0 表示还没到 + int elapsedSeconds() { + final date = _local; + if (date == null) return -1; + return DateTime.now().difference(date).inSeconds; + } + + /// 剩余秒数:该时间 − now。> 0 表示还没到该时间,< 0 表示已过 + int remainingSeconds() { + final date = _local; + if (date == null) return -1; + return date.difference(DateTime.now()).inSeconds; + } + + /// 倒计时文案:"X天 HH:MM:SS"(已过期按 0 处理) + String countdownText() { + final date = _local; + if (date == null) return ''; + final secs = date.difference(DateTime.now()).inSeconds; + final left = secs < 0 ? 0 : secs; //已过期按 0 处理 + return "${left ~/ 86400}天 ${DateTimeUtil.formatHMS(left % 86400, alwaysHour: true)}"; + } +} + +final presaleProvider = PreSaleProvider(); + +class PreSaleProvider extends ChangeNotifier { + static final PreSaleProvider _instance = PreSaleProvider._(); + + PreSaleProvider._(); + + factory PreSaleProvider() => _instance; + + // ===== 数据 ===== + PreSaleModel? preSaleModel; // 预售活动 + AdvanceStatus? advanceStatus; // 预售状态 + AdvancePage? advancePage; // 预售页面资源 + String? luckyDrawH5; // 限时活动抽奖h5 + String? luckyDrawIcon; // 限时活动icon配置 + + // ===== 尾款倒计时 ===== + Timer? _timer; + + /// 付尾款截止时间倒计时,供 ValueListenableBuilder 局部刷新(不走 notifyListeners) + final deadline = ValueNotifier(''); + + // ===== 状态 ===== + bool get isOpen => advanceStatus?.activityStatus == true; //活动是否开启 + + bool get isPayAll => preSaleModel?.detailModel?.status == 4; //是否支付完首尾款 + + bool get isPayFirst => + preSaleModel?.detailModel?.status == 2 || + preSaleModel?.detailModel?.status == 3; //支付首款未付尾款 + + bool get balancePayment => + preSaleModel?.detailModel?.balancePayment ?? false; //是否有尾款支付 + + bool get hasLimit => + advanceStatus?.privilegeLimit?.hasLimit ?? false; //是否有权益限制 + + Remain? get remain => advanceStatus?.privilegeLimit?.remain; //剩余权限次数 + + String? get startTimeMD => preSaleModel?.detailModel?.startTime?.utcToCnMD(); + + /// 开始时间已过秒数。> 0 表示已到达开始时间 + int get _startSeconds => + preSaleModel?.detailModel?.startTime?.elapsedSeconds() ?? -1; + + /// 距截止时间的剩余秒数 + int get endSeconds => + preSaleModel?.detailModel?.endTime?.remainingSeconds() ?? -1; + + /// 是否可以支付尾款 + bool get canPayBalance => isPayFirst && _startSeconds > 0; + + /// 是否展示预售入口:活动开启 & 未付完首尾款 & 未过截止时间 + bool get canShowEnter => isOpen && !isPayAll && endSeconds >= 0; + + /// 金币视频免费观看权益次数(-1 表示没有权益) + int get coinVideoFreeCount { + if (!hasLimit) return -1; + if (isOpen && !isPayAll) { + //有首款权益,判断当前免费权益金币视频数量 + if ((remain?.todayCoinVideoCount ?? -1) >= 0) { + // ⚠️注意业务层判断: 大等于0 是有权益的, (如只有一次,即count =1 ,业务层先减权益数即count = 0,再做其他的逻辑, 是先减) + return remain!.todayCoinVideoCount!; + } + } + return -1; + } + + // ===== 资源图 ===== + /// 支付按钮背景图 + String get payBtnBg { + if (!balancePayment) return advancePage?.buttonBackground ?? ''; + if (_startSeconds > 0) return advancePage?.buttonProcBackground ?? ''; + return advancePage?.buttonWaitBackground ?? ''; + } + + /// 预售小图标入口背景图 + String get enterBg => balancePayment + ? advancePage?.enterBgProc ?? '' + : advancePage?.enterBgWait ?? ''; + + /// 首页弹窗背景图(整张图就是弹窗内容) + String get popBg => balancePayment + ? advancePage?.popBgProc ?? '' + : advancePage?.popBgWait ?? ''; + + /// 预售 banner + String get payBanner { + if (!balancePayment) return advancePage?.banner ?? ''; //开启预定 + return canPayBalance + ? advancePage?.bannerProc ?? '' + : advancePage?.bannerWait ?? ''; //开启支付尾款 + } + + // ===== 数据刷新 ===== + /// 刷新预售全部数据(配置 + 状态,并行) + Future refreshAll() async { + await Future.wait([refreshConfig(), refreshStatus()]); + } + + /// 刷新预售配置(活动信息 + 视频) + Future refreshConfig() async { + preSaleModel = await PreSaleService.fetchConfig(); + // 进入尾款支付期且未结束时,开启全局倒计时定时器(仅一次) + if (canPayBalance && endSeconds > 0 && _timer == null) { + _startTimer(); + } + notifyListeners(); + } + + /// 刷新预售状态(权益/开关等) + Future refreshStatus() async { + final res = await PreSaleService.fetchStatus(); + if (res != null) { + advanceStatus = res; + notifyListeners(); + } + } + + // ===== 业务动作 ===== + //预售弹窗逻辑 + Future showAlert() async { + if (advanceStatus?.activityPopUp != true) return; + if (preSaleModel == null) return; + final status = preSaleModel?.detailModel?.status ?? -1; + if (status == -1 || status == 4) return; //未知或者已经完成 + //预定期直接弹;尾款期只在能付尾款时弹。两期的图由 popBg 自己分流 + if (!balancePayment || canPayBalance) + await Get.dialog(const PreSaleAlert()); + } + + // 拉起支付 + /// [orderTrack] 透传下单埋点(会员中心传入 sessionId / 实验字段;预售独立页可不传) + Future startPay(VipProductModel model, + {PayOrderTrackInfo? orderTrack}) async { + if (isPayFirst && _startSeconds <= 0) { + showToast("还未到尾款支付时间,无法支付~"); + return; + } + if (endSeconds <= 0) { + showToast("已超过预定支付时间,无法支付~"); + return; + } + final base = orderTrack ?? const PayOrderTrackInfo(); + await Get.bottomSheet( + OnlinePayPage( + vipProductModel: model..isPreSale = true, + orderTrack: PayOrderTrackInfo( + sourcePage: base.sourcePage ?? PaySourcePage.vipCenter, + sourceRef: base.sourceRef, + videoId: base.videoId, + activityId: base.activityId, + sessionId: base.sessionId, + //短剧付费墙的归因字段,重建时漏抄就丢了(同 OnlinePayPage._payByLink) + mediaId: base.mediaId, + contentId: base.contentId, + checkoutContextId: base.checkoutContextId, + experimentId: base.experimentId, + experimentVariant: base.experimentVariant, + ), + ), + isScrollControlled: true, + ); + await refreshConfig(); + } + + /// 消费一次「金币视频免费观看」权益 + Future useCoinPrivilege(VideoModel? video) async { + if (video?.videoType == 1) return; // 动漫视频不享受金币权益 + if (video?.freeArea == true) return; + if (video?.isCoinVideo() != true || + (video?.coins ?? 0) <= 0 || + video?.vidStatus?.hasPaid == true) return; + if (!isOpen || isPayAll || !hasLimit) return; //没有首款权益 + final count = remain?.todayCoinVideoCount ?? 0; + remain?.reduceVideoCoinCount(); //有无次数都先本地减,取数要在减之前 + if (count <= 0) return; //已无次数,不必再调接口 + final resp = + await PreSaleService.consumePrivilege(type: PrivilegeType.coinVideo); + if (resp.isSuccess) { + debugLog("权益次数消费成功: ${remain?.todayCoinVideoCount}"); + } + } + + /// 今日下载次数 -1 + void reduceDownload() { + remain?.todayDownloadCount = (remain?.todayDownloadCount ?? 0) - 1; + notifyListeners(); + } + + //跳转h5抽奖 + Future openLuckyDraw() async { + if (luckyDrawH5?.isNotEmpty != true) return; + final token = await netManager.getToken(); + final url = "$luckyDrawH5&token=$token&appUrl=${Address.baseHost}"; + Get.to( + H5Page(url: url, showTitle: false, showClose: true), + opaque: false, + ); + } + + // ===== 私有 ===== + /// 全局尾款支付倒计时定时器 + void _startTimer() { + _timer?.cancel(); // 防重入:先清掉已有定时器,杜绝多重泄漏 + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + // 每秒只更新 deadline,交给 ValueListenableBuilder 局部刷新,不触发全局 notifyListeners + deadline.value = + preSaleModel?.detailModel?.endTime?.countdownText() ?? ''; + if (endSeconds < 0) { + timer.cancel(); + _timer = null; + notifyListeners(); // 到期时通知一次,隐藏预售入口(Consumer 依赖 endSeconds) + } + }); + } +} diff --git a/lib/hj_page/pre_sale/pre_sale_service.dart b/lib/hj_page/pre_sale/pre_sale_service.dart new file mode 100644 index 0000000..4d71400 --- /dev/null +++ b/lib/hj_page/pre_sale/pre_sale_service.dart @@ -0,0 +1,45 @@ +import 'package:hgdj/hj_model/splash/domain_source_model.dart'; +import 'package:hgdj/tools_base/net/base_resp_bean.dart'; +import 'package:hgdj/tools_base/net/http_manager.dart'; + +import 'pre_sale_model.dart'; + +/// 预售卡预付权益类型(枚举名即后端约定值,直接取 name 上传) +enum PrivilegeType { + coinVideo, // 金币视频 + luckDraw, // 抽奖 + aiUndress, // AI脱衣 + download, // 下载 +} + +class PreSaleService { + //金币视频列表+活动详情 + static Future fetchConfig() async { + final res = await httpManager.fetchResponseByGET( + '/video_gold_coin/list', + param: {'pageNumber': 1, 'pageSize': 8}, + jsonTransformation: (json) => PreSaleModel.fromJson(json), + ); + return res.data; + } + + //获取预售状态 + static Future fetchStatus() async { + final res = await httpManager.fetchResponseByGET( + '/product/advanceStatus', + jsonTransformation: (json) => AdvanceStatus.fromJson(json), + ); + return res.data; + } + + //消耗预售卡预付权益 + static Future consumePrivilege({ + required PrivilegeType type, + int count = 1, + }) { + return httpManager.fetchResponseByPOST( + '/mine/privilege/consume', + param: {'count': count, 'privilegeType': type.name}, + ); + } +} diff --git a/lib/hj_page/short_video/short_main_video_logic.dart b/lib/hj_page/short_video/short_main_video_logic.dart new file mode 100644 index 0000000..cc0ba40 --- /dev/null +++ b/lib/hj_page/short_video/short_main_video_logic.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +//短剧频道的二级 tab:写死这 4 个,顺序与 ShortVideoMainPage 的 TabBarView children 一一对应 +const kDramaTabs = ["直播", "抖音", "AI短剧", "热门短剧"]; + +//默认打开 AI短剧 +const kDramaDefaultTab = 2; +const kDramaHotTab = 3; + +/// 短剧频道的 tab 调度。被 MainPageLogic.skipToLive 和 ShortTopMenu 按类型 Get.find, +/// 所以必须保持全局注册、不能加 tag +class ShortVideoMainLogic extends GetxController with GetSingleTickerProviderStateMixin { + // ===== 外部传入 ===== + //只是「进来时停在哪个 tab」,当前 tab 一律读 tabIndex,别混 + final int _initTab; + + ShortVideoMainLogic({required int index}) : _initTab = index; + + // ===== 交互态 ===== + final tabIndex = kDramaDefaultTab.obs; // 当前 tab,背景图跟着它切 + + // ===== Controller ===== + late final TabController tabCtr; + + //初始下标在这里定而不在构造函数里:init: 是构造参数,每次 rebuild 都会 new 一个再丢掉, + //构造函数里干活等于每帧白跑一遍;onInit 由 GetBuilder 只调一次 + @override + void onInit() { + super.onInit(); + tabIndex.value = _initTab >= 0 ? _initTab.clamp(0, kDramaTabs.length - 1) : kDramaDefaultTab; + tabCtr = TabController(length: kDramaTabs.length, vsync: this, initialIndex: tabIndex.value) + ..addListener(_onTabChanged); + } + + // 子 tab 切换只同步下标(背景图跟着变),播放由各播放器按可见性自理 + void _onTabChanged() => tabIndex.value = tabCtr.index; + + void jumpToLive() => tabCtr.index = 0; + + //GetX 生命周期是 onClose(不是 dispose),统一在此释放,避免监听/TabController 泄漏 + @override + void onClose() { + tabCtr + ..removeListener(_onTabChanged) + ..dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/short_video/short_main_video_page.dart b/lib/hj_page/short_video/short_main_video_page.dart new file mode 100644 index 0000000..52b1065 --- /dev/null +++ b/lib/hj_page/short_video/short_main_video_page.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/short_video/short_main_video_logic.dart'; +import 'package:hgdj/hj_page/short_video/short_video_page.dart'; +import 'package:hgdj/hj_page/short_video/view/short_top_menu.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../drama/drama_feed_page.dart'; +import '../drama/drama_list_page.dart'; +import '../live/live_main_page.dart'; + +/// 短剧频道:直播 / 抖音 / AI短剧 / 热门短剧 +class ShortVideoMainPage extends StatelessWidget { + /// 指定初始 tab;<0(默认) 表示用 [kDramaDefaultTab] + final int index; + + const ShortVideoMainPage({super.key, this.index = -1}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: ShortVideoMainLogic(index: index), + builder: (logic) => Stack( + fit: StackFit.expand, + children: [ + //只有直播 tab 铺这张背景图。Positioned 必须是 Stack 的直接子节点,Obx 只包内部 + Positioned.fill( + child: Obx( + () => logic.tabIndex.value == 0 + ? Image.asset('live_main_bg2.webp'.livePath, + alignment: Alignment.topCenter, fit: BoxFit.cover) + : logic.tabIndex.value == kDramaHotTab + ? const ColoredBox(color: Color(0xFF05000E)) + : const SizedBox.shrink(), + ), + ), + SafeArea( + bottom: false, + child: Stack( + fit: StackFit.expand, + children: [ + RepaintBoundary( + child: TabBarView( + controller: logic.tabCtr, + physics: const NeverScrollableScrollPhysics(), + //顺序必须与 kDramaTabs 一致。直播/热门短剧是普通页面,要给顶部菜单让位;两个信息流页自己铺满 + children: [ + const Padding( + padding: EdgeInsets.only(top: 44), + child: LiveMainPage()), + ShortVideoPage( + type: VideoListType.RECOMMEND, + tabIndex: 1, + superTabCtr: logic.tabCtr) + .keepAlive, + DramaFeedPage(tabIndex: 2, superTabCtr: logic.tabCtr) + .keepAlive, + const Padding( + padding: EdgeInsets.only(top: 44), + child: DramaListPage()) + .keepAlive, + ], + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: ShortTopMenu(tabCtr: logic.tabCtr)), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/short_video/short_video_logic.dart b/lib/hj_page/short_video/short_video_logic.dart new file mode 100644 index 0000000..1fde127 --- /dev/null +++ b/lib/hj_page/short_video/short_video_logic.dart @@ -0,0 +1,191 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/short_video/short_video_page.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/api_service/vid_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/history_util.dart'; +import 'package:hgdj/tools_base/event_bus/event_bus_util.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; +import 'package:hgdj/tools_base/ad_manager.dart'; + +import '../../hj_model/splash/ads_model.dart'; +import '../../hj_model/video_model.dart'; +import '../../tools_base/debug_log.dart'; + +class ShortVideoLogic extends GetxController { + // GetBuilder 局部刷新 id:当前页变化时只刷信息流 + static const idFeed = 'feed'; + + bool isFirstLoaded = false; // 首屏是否加载完成(false 时显示 loading),变化走整页 update() + bool isLoadingData = false; // 显示倒数第6个视频的时候,主动翻页加载数据,isLoadingData防网络去重的作用 + int pageViewIndex = 0; // 显示的当前页数 + int currentPage = 1; + bool hasNext = true; + List? videoList; + late PageController pageCtr; + final ValueNotifier isScrolling = ValueNotifier(false); // 上下滑动切视频时隐藏进度条 + final VideoListType listType; + final TabController? superTabCtr; // 外层主 tab,用于切到本 tab 时播放/离开时暂停 + final int? tabIndex; + VideoModel? secondVideoModel; //入参视频详情数据(需要通过视频详情接口获取视频权限) + late StreamSubscription _followSub; + List get insertAdList => AdManager().adsByType(12); + ShortVideoLogic({ + this.currentPage = 1, + this.hasNext = true, + this.videoList, + int pageIndex = 0, + this.listType = VideoListType.SECOND, + this.superTabCtr, + this.tabIndex, + }) { + pageViewIndex = pageIndex; + } + + @override + void onInit() { + _followSub = eventBus.on(_followListenCallback); + pageCtr = PageController(initialPage: pageViewIndex); + superTabCtr?.addListener(_listenTabIndex); + super.onInit(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + if (listType == VideoListType.SECOND) { + if (videoList?.length == 1) { + await _loadVideoDetail(); + } else { + _markLoaded(); + } + } + initData(); + }); + } + + // 外层 tab 切换:首次切到本 tab 且无数据则加载(播/停由各播放器按可见性自理) + void _listenTabIndex() { + if (superTabCtr?.index == tabIndex && videoList?.isEmpty == true) { + initData(); + } + } + + // 首屏加载完成:切 loading→内容,整页刷新 + void _markLoaded() { + isFirstLoaded = true; + update(); + } + + void _followListenCallback(CollectStatusModel model) { + // 当前ui状态通过CollectStatusWrapper已经处理了 + // 这里处理列表数据(只需要改数据),不需要刷新ui + for (final video in videoList ?? []) { + if (model.isFollowed != null && model.uid == video.publisher?.uid) { + video.publisher?.hasFollowed = model.isFollowed; + } + } + } + + Future _loadVideoDetail() async { + try { + VideoModel? videoModel = + await VidService.getDetail(videoList?.first.id ?? ''); + if (videoModel != null) { + secondVideoModel = videoModel; + videoList?.removeAt(0); + videoList?.insert(0, videoModel); + } + } catch (e) { + debugLog(e.toString()); + } + _markLoaded(); + } + + void initData() { + if (listType == VideoListType.SECOND) { + _markLoaded(); + return; + } + isFirstLoaded = false; + update(); + loadData(isInit: true); + } + + Future loadData( + {int page = 1, int pageSize = 12, bool isInit = false}) async { + if (listType == VideoListType.SECOND) { + return; + } + // 推荐列表:是否还能翻页以 hasNext 为准,不再用 totalPages + if (page > 1 && !hasNext) return; + if (isLoadingData) return; + try { + isLoadingData = true; // 进入请求即上锁,防止快速翻页/刷新重复拉取 + List list = []; + try { + if (listType == VideoListType.FOLLOW) { + final resp = await CommonService.fetchFollowDynamics( + page: page, size: pageSize, type: 1); + list = resp?.videos ?? []; + hasNext = resp?.hasNext ?? false; + } else { + // 推荐:固定 pageSize=20,只传 pageNumber/pageSize + final resp = await VidService.getRecommendList(page, pageSize: 20); + debugLog('short recommend queueVersion=${resp?.queueVersion}'); + list = resp?.vInfo ?? []; + hasNext = resp?.hasNext ?? false; + } + } catch (e) { + debugLog(e); + } + currentPage = page; + videoList ??= []; + if (page == 1) { + videoList = []; + if (isInit && secondVideoModel != null) { + videoList?.add(secondVideoModel!); + } + } + videoList?.addAll(list); + if (listType == VideoListType.RECOMMEND) { + videoList?.removeWhere((element) => element.isRandomAd()); + AdManager().insertRandomAds(videoList ?? [], insertAdList, adGap: 6); + } + } catch (e) { + videoList ??= []; + debugLog("短视频列表获取失败:$e"); + } + isLoadingData = false; + _markLoaded(); + } + + void onPageChanged(int index) { + if (pageViewIndex != index) { + pageViewIndex = index; + update([idFeed]); // 当前页变化,只刷信息流(各 item 重算 isCurrentPage) + // 滑到即记:不看播放权限、不看播放器是否创建成功、不看播放进度,只要滑到这一帧就入历史。 + // 广告位跳过;HistoryUtil.insert 内部按 id 去重(已有则刷新到最前),所以重复滑无副作用。 + final list = videoList; + if (list != null && index >= 0 && index < list.length) { + final video = list[index]; + if (!video.isRandomAd()) { + HistoryUtil.insert(video, MediaStyle.ShortVideo); + } + } + } + if (hasNext && videoList!.length - index <= 6) { + loadData(page: currentPage + 1); + } + } + + // GetX 释放只走 onClose(),不会调用 dispose(),清理必须放这里 + @override + void onClose() { + _followSub.cancel(); + eventBus.off(_followSub); + superTabCtr?.removeListener(_listenTabIndex); + pageCtr.dispose(); + isScrolling.dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/short_video/short_video_page.dart b/lib/hj_page/short_video/short_video_page.dart new file mode 100644 index 0000000..17a8e23 --- /dev/null +++ b/lib/hj_page/short_video/short_video_page.dart @@ -0,0 +1,146 @@ +// ignore_for_file: constant_identifier_names + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/short_video/short_video_logic.dart'; +import 'package:hgdj/hj_page/short_video/view/feed_ad_view.dart'; +import 'package:hgdj/hj_page/short_video/view/short_video_player.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +/// SECOND 短视频只有一个视频(入参视频需要通过视频详情接口获取权限),需要加载推荐列表数据; +/// SECONDList 传过来的不止一个视频,不需要加载推荐列表数据(列表视频数据,都需要视频详情接口获取权限); +enum VideoListType { FOLLOW, RECOMMEND, SECOND } + +class ShortVideoPage extends StatefulWidget { + final VideoListType type; + final TabController? superTabCtr; + final int? tabIndex; // + final List? videoList; + final int pageIndex; + + const ShortVideoPage({ + super.key, + this.type = VideoListType.SECOND, + this.superTabCtr, + this.tabIndex, + this.videoList, + this.pageIndex = 0, + }); + + @override + State createState() { + return _ShortVideoPageState(); + } +} + +class _ShortVideoPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, // 可重复 push + 多 tab 并存,按实例隔离 + init: ShortVideoLogic( + listType: widget.type, + pageIndex: widget.pageIndex, + videoList: widget.videoList, + superTabCtr: widget.superTabCtr, + tabIndex: widget.tabIndex, + ), + builder: _buildBody, // 整页 update():loading / 错误 / 信息流 三态切换 + ); + } + + Widget _buildBody(ShortVideoLogic logic) { + final Widget content; + if (!logic.isFirstLoaded) { + content = const LoadingCenterWidget(); + } else if (logic.videoList?.isNotEmpty != true) { + content = CErrorWidget(retryOnTap: logic.initData); + } else { + content = _buildFeed(logic); + } + return Material(color: Colors.transparent, child: content); + } + + /// 短视频信息流:竖向 PageView + (SECOND)返回按钮(进度条已内嵌到各 ShortVideoPlayer) + Widget _buildFeed(ShortVideoLogic logic) { + return Stack( + fit: StackFit.expand, + children: [ + RefreshIndicator( + onRefresh: () => logic.loadData(), + child: _pageView(logic), + ), + if (widget.type == VideoListType.SECOND) _backButton(), + ], + ); + } + + /// 信息流局部刷新:当前页变化时只 update([idFeed]),各 item 重算 isCurrentPage + Widget _pageView(ShortVideoLogic logic) { + return GetBuilder( + tag: uniqueTag, + id: ShortVideoLogic.idFeed, + // 上下滑动开始/结束 → 切换 isScrolling,各播放器据此隐藏/显示进度条 + builder: (logic) => NotificationListener( + onNotification: (n) { + if (n is ScrollStartNotification) { + logic.isScrolling.value = true; + } else if (n is ScrollEndNotification) { + logic.isScrolling.value = false; + } + return false; + }, + child: PageView.builder( + onPageChanged: logic.onPageChanged, + physics: const ClampingScrollPhysics(), + controller: logic.pageCtr, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + itemCount: logic.videoList?.length ?? 0, + itemBuilder: (context, index) => _pageItem(logic, index), + ), + ), + ); + } + + /// 单页:广告位 or 视频播放器(进度条由播放器自管) + Widget _pageItem(ShortVideoLogic logic, int index) { + final VideoModel videoInfo = logic.videoList![index]; + if (videoInfo.isRandomAd()) { + return FeedAdView(mediaInfo: videoInfo); + } + return ShortVideoPlayer( + key: ValueKey(videoInfo.id), + index: index, + isCurrentPage: logic.pageViewIndex == index, + videoInfo: videoInfo, + listType: widget.type, + isScrolling: logic.isScrolling, + ); + } + + Widget _backButton() { + return Positioned( + top: 20, + child: Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.only(top: 12), + child: IconButton( + icon: const Icon( + Icons.arrow_back_ios, + color: Colors.white, + shadows: [ + Shadow( + color: Colors.black, offset: Offset(1, 1), blurRadius: 1), + ], + ), + onPressed: () => Get.back(), + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/short_video/view/feed_ad_view.dart b/lib/hj_page/short_video/view/feed_ad_view.dart new file mode 100644 index 0000000..ba1b851 --- /dev/null +++ b/lib/hj_page/short_video/view/feed_ad_view.dart @@ -0,0 +1,30 @@ +import 'package:flutter/cupertino.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/routers/jump_router.dart'; + +import '../../../hj_model/splash/ads_model.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; + +/// 短视频信息流里的整屏图片广告位(PageView item),点击跳落地页 +class FeedAdView extends StatelessWidget { + final VideoModel? mediaInfo; + const FeedAdView({super.key, this.mediaInfo}); + + @override + Widget build(BuildContext context) { + final AdsInfoModel? adv = mediaInfo?.randomAdsInfo; + return GestureDetector( + onTap: () => pushToPageByLink(adv?.href), + // 强制全屏铺满(与视频页一致),避免 iOS 上未撑满导致显示不全 + child: SizedBox.expand( + child: NetworkImageLoader( + imageUrl: adv?.cover ?? "", + borderRadius: 0, + width: double.infinity, + height: double.infinity, + fit: BoxFit.fill, + ), + ), + ); + } +} diff --git a/lib/hj_page/short_video/view/float_ad_view.dart b/lib/hj_page/short_video/view/float_ad_view.dart new file mode 100644 index 0000000..557ab98 --- /dev/null +++ b/lib/hj_page/short_video/view/float_ad_view.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/splash/ads_model.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/ad_manager.dart'; +import 'package:hgdj/tools_base/widget/net_image_widget.dart'; + +/// 短视频左上角浮动广告(position=8) +class FloatAdView extends StatefulWidget { + const FloatAdView({super.key}); + + @override + State createState() => _FloatAdViewState(); +} + +class _FloatAdViewState extends State { + static bool _isDismissed = false; // 关闭后本次启动内所有短视频页都不再展示 + late final AdsInfoModel? _adInfo = AdManager().adsByType(8).firstOrNull; + + @override + Widget build(BuildContext context) { + final ad = _adInfo; // 取局部变量做类型提升,后续无需 ! 断言 + if (_isDismissed || ad == null || !AdManager().showAbTestAd) + return const SizedBox.shrink(); + final cover = ad.cover; + if (cover == null || cover.isEmpty) return const SizedBox.shrink(); + + return Positioned( + top: Get.mediaQuery.padding.top + 45, + left: 16, + child: SizedBox( + width: 70, + height: 70, + child: Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: () => pushToPageByLink(ad.href), + child: NetworkImageLoader( + imageUrl: cover, + width: 70, + height: 70, + fit: BoxFit.cover, + borderRadius: 4, + ), + ), + Positioned( + top: 2, + right: 2, + child: GestureDetector( + onTap: () => setState(() => _isDismissed = true), + child: Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + size: 12, + color: Color(0xff141414), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/short_video/view/media_action_menu.dart b/lib/hj_page/short_video/view/media_action_menu.dart new file mode 100644 index 0000000..1979c0a --- /dev/null +++ b/lib/hj_page/short_video/view/media_action_menu.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/tools_base/video_download/download_button.dart'; +import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart'; +import 'package:hgdj/hj_utils/api_service/acg_service.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/api_service/drama_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:uuid/uuid.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../alert/video/share_media_dialog.dart'; +import '../../../alert/video/video_line_menu_alert.dart'; +import '../../../hj_utils/api_service/mine_service.dart'; +import '../../../tools_base/debug_log.dart'; +import '../../../tools_base/global_store/store.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import '../../comment/comment_alert.dart'; +import '../../home/widget/collect_status_wrapper.dart'; +import '../../user_center_page/user_center_page.dart'; + +/// 短视频 / 短剧共用的右侧动作菜单:点赞、收藏、评论、分享、下载、线路、收起。 +/// 两者只差三处,全部由 [style] 分流: +/// - [MediaStyle.ShortVideo]:多「作者头像+关注」,互动与下载对象都是这条视频 +/// - [MediaStyle.Drama]:没有作者人设,互动对象是**整部剧**(不是当前这一集), +/// 但下载对象是**当前这一集**;分享完还要回传服务端累计互动数(推荐分要吃) +class MediaActionMenu extends StatefulWidget { + final MediaStyle style; + final VideoModel? videoModel; + final VideoPlayerController? playerCtr; + final VoidCallback? onSwitchLine; //切换 CDN 线路后重新起播 + + const MediaActionMenu({ + super.key, + required this.style, + this.videoModel, + this.playerCtr, + this.onSwitchLine, + }); + + @override + State createState() => _MediaActionMenuState(); +} + +class _MediaActionMenuState extends State + with TickerProviderStateMixin { + bool _isFollowLoading = false; + bool _isCollectLoading = false; + bool _isLikeLoading = false; + + /// 关注成功后 + 号「旋转一圈 + 缩小消失」动画,只有短视频有关注按钮,短剧不建。 + /// 不能写成 late final:短剧路径下全程不访问它,dispose() 里那句释放会成为「首次访问」, + /// 于是在 unmount 阶段才去 createTicker → TickerMode.of(context) → 查已 deactivate 的祖先直接抛异常 + AnimationController? _followAniCtr; + + //局部刷新信号:点赞/收藏/关注各刷各的,避免整条菜单重建 + final ValueNotifier _likeTick = ValueNotifier(0); + final ValueNotifier _collectTick = ValueNotifier(0); + final ValueNotifier _followTick = ValueNotifier(0); + + //各 item 之间的统一间距 + int get itemGap => 10; + + bool get _isDrama => widget.style == MediaStyle.Drama; + + VideoModel? get _video => widget.videoModel; + + //互动对象:短剧全部挂在剧上,不是当前这一集 + String? get _objId => _isDrama ? _video?.dramaInfo?.id : _video?.id; + + //点赞接口的业务类型 + String get _likeType => _isDrama ? 'drama' : 'SHORT'; + + //评论接口的对象类型(和点赞不同口径:短视频这里是 video 不是 SHORT) + String get _commentType => _isDrama ? 'drama' : 'video'; + + bool get isFollow => _video?.publisher?.hasFollowed ?? false; + + bool get isMy => globalStore.isMe(_video?.publisher?.uid); + + bool get isCollect => _video?.vidStatus?.hasCollected ?? false; + + bool get isLike => _video?.vidStatus?.hasLiked ?? false; + + @override + void initState() { + super.initState(); + VideoMenuSwitch.addListen(_menuListen); + if (!_isDrama) { + _followAniCtr = AnimationController( + duration: const Duration(milliseconds: 500), vsync: this); + } + } + + @override + void dispose() { + VideoMenuSwitch.removeLister(_menuListen); + _followAniCtr?.dispose(); + _likeTick.dispose(); + _collectTick.dispose(); + _followTick.dispose(); + super.dispose(); + } + + void _menuListen(bool isShow) { + if (mounted) setState(() {}); + } + + //局部刷新:只重建依赖该信号的子树(点赞/收藏/关注互不牵连) + Widget _refreshOn(ValueNotifier tick, Widget Function() builder) => + ValueListenableBuilder( + valueListenable: tick, builder: (_, __, ___) => builder()); + + @override + Widget build(BuildContext context) { + final bool show = VideoMenuSwitch.isShow; + return SizedBox( + width: 46, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // 动作区:显隐淡入淡出 + 右滑;隐藏时保留占位(眼睛按钮位置不跳动)且不可点击, + // 子项常驻避免反复重建/头像重载 + MenuReveal( + visible: show, + offset: const Offset(0.35, 0), // 隐藏时向右滑出 + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + //短剧没有作者人设,不出头像和关注 + if (!_isDrama) ...[ + _followItem(), + itemGap.sizeBoxH, + ], + _refreshOn( + _likeTick, + () => menuActionItem( + (_video?.likeCount ?? 0).countStr, + isLike + ? 'like_red.png'.commonImgPath + : 'like_white.png'.commonImgPath, + onTap: _onLikeEvent, + ), + ), + itemGap.sizeBoxH, + menuActionItem( + _video?.commentCount.countOr("评论") ?? "评论", + 'comment.png'.videoPath, + onTap: _onCommentEvent, + ), + itemGap.sizeBoxH, + _refreshOn( + _collectTick, + () => menuActionItem( + isCollect ? "已收藏" : "收藏", + isCollect + ? 'collect_red.png'.commonImgPath + : 'collect_white.png'.commonImgPath, + onTap: _onCollectEvent, + ), + ), + itemGap.sizeBoxH, + //顺序按设计稿:下载在分享前面。 + //短剧显式传 style,让缓存记录落进短剧桶(不传会按 isShort 归到短视频, + //在「我的缓存」里就会跑到抖音 tab 下) + DownloadButton( + video: _video, + isShort: true, + style: _isDrama ? MediaStyle.Drama : null, + ), + itemGap.sizeBoxH, + menuActionItem('分享', 'share_arrow.png'.videoPath, + onTap: _onShareEvent), + itemGap.sizeBoxH, + menuActionItem( + '切换', + "line_white2.png".videoPath, + onTap: () async { + if (await VideoLineMenuAlert.show()) + widget.onSwitchLine?.call(); + setState(() {}); + }, + ), + itemGap.sizeBoxH, + ], + ), + ), + // 眼睛按钮常驻:仅切换图标(eye_open/eye_close),menuActionItem 内置 AnimatedSwitcher 交叉淡入 + menuActionItem( + "", (show ? "eye_open.png" : "eye_close.png").videoPath, + onTap: () { + //广播给本页其他播放器/进度条,再刷新自身 + VideoMenuSwitch.toggle(); + setState(() {}); + }), + ], + ), + ); + } + + //作者头像 + 关注 + 号(仅短视频) + Widget _followItem() { + return Stack( + alignment: Alignment.bottomCenter, + children: [ + InkWell( + enableFeedback: false, + onTap: () => + Get.to(() => UserCenterPage(uid: _video?.publisher?.uid ?? 0)), + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: NetworkImageLoader( + imageUrl: _video?.publisher?.portrait ?? '', + width: 46, + height: 46, + borderRadius: 30, + ), + ), + ), + InkWell( + enableFeedback: false, + onTap: _onFollowEvent, + child: _refreshOn( + _followTick, + () => CollectStatusWrapper( + videoModel: _video, builder: _buildFollowPlus), + ), + ), + ], + ); + } + + //关注 + 号:未关注时静态显示;关注成功后转一圈缩小消失;动画结束隐藏 + Widget _buildFollowPlus() { + final ctr = _followAniCtr; + if (ctr == null || (isFollow && !ctr.isAnimating)) { + return const SizedBox(height: 16); + } + return RotationTransition( + turns: ctr, + child: ScaleTransition( + scale: Tween(begin: 1, end: 0).animate( + CurvedAnimation( + parent: ctr, + curve: const Interval(0.5, 1.0, curve: Curves.easeIn)), + ), + child: Image.asset('add_red.webp'.videoPath, width: 16, height: 16), + ), + ); + } + + // ============ 互动 ============ + + //短剧收藏走书架接口(对象是剧),短视频走通用收藏 + void _onCollectEvent() async { + if (_isCollectLoading) return; + _isCollectLoading = true; + try { + final preCollect = isCollect; + if (_isDrama) { + await (preCollect + ? ACGService.deleteBookshelf(_objId ?? '') + : ACGService.addBookshelf(_objId ?? '')); + } else { + await MineService.postCollect(_objId, "SHORT", !preCollect); + } + _video?.collectCount = (_video?.collectCount ?? (preCollect ? 1 : 0)) + + (preCollect ? -1 : 1); + _video?.vidStatus?.hasCollected = !preCollect; + if (!preCollect) showToast("收藏成功"); + _collectTick.value++; + } catch (e) { + debugLog('收藏失败', e.toString()); // 吞掉异常,只复位 loading + } finally { + _isCollectLoading = false; + } + } + + void _onLikeEvent() async { + if (_isLikeLoading) return; + _isLikeLoading = true; + try { + final preLike = isLike; + if (preLike) { + await CommonService.cancelLike(_objId, _likeType); + } else { + await CommonService.sendLike(_objId, _likeType); + showToast("已点赞"); + } + _video?.likeCount = + (_video?.likeCount ?? (preLike ? 1 : 0)) + (preLike ? -1 : 1); + _video?.vidStatus?.hasLiked = !preLike; + _likeTick.value++; + } catch (e) { + debugLog('点赞失败', e.toString()); + } finally { + _isLikeLoading = false; + } + } + + void _onCommentEvent() async { + final isPlaying = widget.playerCtr?.value.isPlaying ?? false; + widget.playerCtr?.pause(); + await showCommentDialog( + _objId ?? '', + objType: _commentType, + commentCount: _video?.commentCount, + ); + if (isPlaying) widget.playerCtr?.play(); + } + + void _onShareEvent() async { + final isPlaying = widget.playerCtr?.value.isPlaying ?? false; + widget.playerCtr?.pause(); + await Get.dialog(ShareMediaDialog(videoModel: _video)); + //短剧分享要回服务端累计互动数(推荐分要吃),eventId 幂等:同一次分享重试沿用同一个 + if (_isDrama) { + DramaService.shareOutput( + content: globalStore.meInfo?.promoteURL ?? '', + mediaId: _objId, + contentId: _video?.dramaEpisode?.id, + eventId: const Uuid().v4(), + ); + } + if (isPlaying) widget.playerCtr?.play(); + } + + void _onFollowEvent() async { + if (isMy) { + showToast("自己不能关注自己"); + return; + } + if (_isFollowLoading) return; + _isFollowLoading = true; + try { + final followStatus = !isFollow; + await MineService.getFollow(_video?.publisher?.uid ?? 0, followStatus); + _video?.publisher?.hasFollowed = followStatus; + if (followStatus) { + //关注成功:+ 号转一圈缩小消失,结束后重建隐藏 + _followAniCtr?.forward(from: 0).whenComplete(() { + if (mounted) _followTick.value++; + }); + } else { + _followAniCtr?.reset(); //取消关注:+ 号复位显示 + } + showToast(followStatus ? "关注成功" : "已取消关注"); + } catch (e) { + debugLog('getFollow', e.toString()); + } + _isFollowLoading = false; + if (mounted) _followTick.value++; + } +} diff --git a/lib/hj_page/short_video/view/short_top_menu.dart b/lib/hj_page/short_video/view/short_top_menu.dart new file mode 100644 index 0000000..20b8692 --- /dev/null +++ b/lib/hj_page/short_video/view/short_top_menu.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; + +import '../../home/search_page/search_main_page.dart'; +import '../short_main_video_logic.dart'; + +class ShortTopMenu extends StatelessWidget { + final TabController? tabCtr; + + const ShortTopMenu({super.key, this.tabCtr}); + + @override + Widget build(BuildContext context) { + // 选中态、下划线位移全交给 TabBar 自己按 tabCtr 动画,不用 Obx 整条重建 + //高度 48 与 ShortVideoMainPage 里两个普通 tab 的 Padding(top: 44) 对应,改一处要同步 + return Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + 24.sizeBoxW, + Expanded( + child: TabBar( + //渐变下划线:宽 16 高 4 贴底,色值同 CustomIndicator 默认的 gradientColors + indicator: CustomIndicator( + width: 16, + height: 4, + isGradient: true, + borderRadius: BorderRadius.circular(2), + ), + labelColor: Colors.white.withValues(alpha: 0.9), + labelStyle: + const TextStyle(fontSize: 15, fontWeight: FontWeight.w500), + unselectedLabelColor: Colors.white.withValues(alpha: 0.55), + unselectedLabelStyle: const TextStyle(fontSize: 15), + labelPadding: EdgeInsets.zero, + controller: tabCtr, + isScrollable: true, + tabAlignment: TabAlignment.center, + tabs: kDramaTabs.map(_menuItem).toList(), + ), + ), + InkWell( + enableFeedback: false, + onTap: () => + Get.to(SearchMainPage(initialResultType: MediaStyle.Drama)), + child: Container( + padding: const EdgeInsets.only(bottom: 2), + child: Image.asset( + 'search_icon.png'.commonImgPath, + width: 24, + color: const Color(0xff989898), + ), + ), + ) + ], + ), + ); + } + + /// 单个 tab:只画文字(直播原来是图标,改版后统一成文字)。 + /// 底部留 7 = 与文字间距 3 + 指示器高 4,腾给贴底绘制的 CustomIndicator + Widget _menuItem(String title) => Container( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 7), + child: Text(title), + ); +} diff --git a/lib/hj_page/short_video/view/short_video_menu.dart b/lib/hj_page/short_video/view/short_video_menu.dart new file mode 100644 index 0000000..0bb0d8e --- /dev/null +++ b/lib/hj_page/short_video/view/short_video_menu.dart @@ -0,0 +1,384 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/short_video/view/media_action_menu.dart'; +import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart'; +import 'package:hgdj/hj_page/video/view/long_video_status.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/free_play_manager.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/ad_manager.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../hj_model/splash/watch_count_model.dart'; +import '../../../routers/jump_router.dart'; +import '../../../tools_base/global_store/store.dart'; +import '../../../tools_base/widget/shrink_wrap.dart'; +import '../../home/tag/video_tag_page.dart'; + +/// 短视频操作台:左侧信息栏 + 右侧动作菜单 + 中间暂停播放按钮 +class ShortVideoMenu extends StatefulWidget { + final VideoPlayerController? playerCtr; + final VideoModel? videoModel; + final WatchCount? watchCount; + final VoidCallback? onBuy; + final VoidCallback? onSwitchLine; //切换 CDN 线路后重新起播 + + const ShortVideoMenu({ + super.key, + this.playerCtr, + this.videoModel, + this.watchCount, + this.onBuy, + this.onSwitchLine, + }); + + @override + State createState() { + return _ShortVideoMenuState(); + } +} + +class _ShortVideoMenuState extends State { + //左侧各 item 之间的统一间距 + double get gapValue => 14; + + //当前视频的付费/权限状态(已购/需VIP/需金币等) + LongVideoStatus get videoStatus => longVideoStatus(widget.videoModel); + + @override + void initState() { + super.initState(); + VideoMenuSwitch.addListen(_menuListen); + } + + @override + void dispose() { + VideoMenuSwitch.removeLister(_menuListen); + super.dispose(); + } + + void _menuListen(bool isShow) { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(bottom: 4), + child: Stack( + alignment: Alignment.center, + children: [ + Container( + //底部留白避开文字广告条:无广告 40,有广告(type33) 56 + padding: EdgeInsets.only( + bottom: AdManager().adsByType(33).isEmpty ? 40 : 56), + child: Row( + children: [ + Expanded( + child: MenuReveal( + visible: VideoMenuSwitch.isShow, + offset: const Offset(-0.1, 0), // 隐藏时向左滑出 + child: _buildLeftPanel(), + ), + ), + const SizedBox(width: 8), + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + MediaActionMenu( + style: MediaStyle.ShortVideo, + videoModel: widget.videoModel, + playerCtr: widget.playerCtr, + onSwitchLine: widget.onSwitchLine, + ), + ], + ), + 12.sizeBoxW, + ], + ), + ), + _buildCenterPlay(), + ], + ), + ); + } + + // ============ 左侧信息栏(金币购买 / VIP 入口 + 作者 / 标题 / 标签)============ + + Widget _buildLeftPanel() { + return Container( + padding: const EdgeInsets.only(left: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _buildBuyItem(), + _buildVipItem(), + _buildAuthorItem(), + _buildTitleItem(), + _buildTagItem(), + ], + ), + ); + } + + //作者名,点击暂停并进入个人主页 + Widget _buildAuthorItem() { + return Container( + padding: EdgeInsets.only(top: gapValue), + child: Row( + children: [ + GestureDetector( + behavior: HitTestBehavior.deferToChild, + onTap: () { + widget.playerCtr?.pause(); + pushToPersonCenter(widget.videoModel?.publisher?.uid); + }, + child: Text( + "@${widget.videoModel?.publisher?.name ?? ""}", + maxLines: 1, + style: TextStyle( + fontSize: 12, + color: Color(0x73FFFFFF), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + //视频标题(最多2行,空标题不显示) + Widget _buildTitleItem() { + if ((widget.videoModel?.title?.trim() ?? "").isEmpty) { + return SizedBox(); + } + return Container( + padding: EdgeInsets.only(top: gapValue), + child: Text( + widget.videoModel?.title?.trim() ?? "", + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 12, + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.05), + offset: const Offset(1, 1), + blurRadius: 5, + ), + ], + ), + ), + ); + } + + //金币视频专属:依次展示 已购买 / 已享金币特权 / 免费观影剩余次数 / 金币解锁按钮 + Widget _buildBuyItem() { + //自己的作品、免费区、非金币视频 → 不展示 + if (globalStore.isMe(widget.videoModel?.publisher?.uid)) { + return const SizedBox(); + } + if (widget.videoModel?.freeArea == true) { + return const SizedBox(); + } + if (widget.videoModel?.isCoinVideo() != true) { + return const SizedBox(); + } + if (widget.videoModel?.vidStatus?.hasPaid == true) { + return Container( + margin: EdgeInsets.only(top: gapValue), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + color: Color(0xffFF9000), + ), + child: const Text( + "已购买", + style: TextStyle( + color: Color(0xff292929), + fontSize: 12, + ), + ), + ); + } + if (videoStatus.type == LongVideoStatusType.coinFree) { + return Container( + margin: EdgeInsets.only(top: gapValue), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(3)), + color: Color(0x99000000), + ), + child: Text( + videoStatus.desc, + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ); + } + if ((widget.watchCount?.coinWatchCount ?? -1) >= 0) { + return Container( + margin: EdgeInsets.only(top: gapValue), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(3)), + color: Color(0x99000000), + ), + child: Text( + "免费金币观影数剩余: ${widget.watchCount?.coinWatchCount}次", + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ); + } + return Container( + margin: EdgeInsets.only(top: gapValue), + child: GestureDetector( + onTap: () async { + widget.playerCtr?.pause(); + widget.onBuy?.call(); + }, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Color(0xffFF9000), + borderRadius: BorderRadius.circular(3), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${widget.videoModel?.coins}金币 解锁", + style: TextStyle( + color: Color(0xff000000), + fontSize: 12, + ), + ) + ], + ), + ), + ), + ); + } + + //非金币视频的 VIP 入口:依次展示 已享VIP特权 / 免费观看剩余次数 / 开通会员按钮 + Widget _buildVipItem() { + //自己的作品、免费区、已购买、金币视频 → 不展示(金币视频走 _buildBuyItem) + if (globalStore.isMe(widget.videoModel?.publisher?.uid)) { + return const SizedBox(); + } + if (widget.videoModel?.freeArea == true) { + return const SizedBox(); + } + if (widget.videoModel?.vidStatus?.hasPaid == true) { + return const SizedBox(); + } + if (widget.videoModel?.isCoinVideo() == true) { + return const SizedBox(); + } + int watchValue = widget.watchCount?.watchCount ?? 0; + if (FreePlayManager().useFreePlay(widget.videoModel) && watchValue < 0) { + // 免费观看次数内观看的视频, 重新观看,视频是可以观看, 但次数小于0时不显示 + return SizedBox(); + } + + if (videoStatus.type == LongVideoStatusType.vipFree) { + return Container( + margin: EdgeInsets.only(top: gapValue), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(3)), + color: Color(0x99000000), + ), + child: Text( + "已享VIP免费特权", + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ); + } + + bool isShowFreeCount = (watchValue >= 0); + return InkWell( + enableFeedback: false, + onTap: () async { + //还有免费次数时按钮仅作展示,不触发购买 + if (isShowFreeCount) return; + widget.onBuy?.call(); + }, + child: Container( + margin: EdgeInsets.only(top: gapValue), + padding: const EdgeInsets.fromLTRB(10, 2, 10, 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + gradient: LinearGradient( + colors: [ + isShowFreeCount ? Color(0x1fFF9D00) : Color(0xffFFE580), + isShowFreeCount ? Color(0x1fFF9D00) : Color(0xffFACC15), + ], + ), + ), + child: Text( + isShowFreeCount ? "免费视频剩余${watchValue + 1}次" : "开通会员 畅享完整版", + style: TextStyle( + color: isShowFreeCount ? Color(0xffFF9D00) : Color(0xff7B0000), + fontSize: 12), + ), + ), + ); + } + + //标签横排(最多1行),点击暂停并进入标签聚合页 + Widget _buildTagItem() { + if (widget.videoModel?.tags?.isNotEmpty != true) return const SizedBox(); + return Container( + padding: EdgeInsets.only(top: gapValue), + child: _tagWrap(), + ); + } + + Widget _tagWrap() { + return ShrinkWrap( + spacing: 0, + runSpacing: 6, + maxLines: 1, + children: widget.videoModel?.tags + ?.map((e) => InkWell( + enableFeedback: false, + onTap: () { + widget.playerCtr?.pause(); + Get.to( + VideoTagPage(e, isShortStyle: true), + preventDuplicates: true, + ); + }, + child: Container( + padding: EdgeInsets.fromLTRB(8, 3, 8, 3), + margin: EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + "#${e.name}", + style: TextStyle( + color: Color(0xffffffff), + fontSize: 12, + ), + ), + ), + )) + .toList() ?? + [], + ); + } + + // ============ 中间播放/暂停按钮(play↔pause 图标形变 + 淡入淡出)============ + + Widget _buildCenterPlay() { + final ctr = widget.playerCtr; + if (ctr == null) return const SizedBox.shrink(); + return CenterPlayPause(controller: ctr); + } +} diff --git a/lib/hj_page/short_video/view/short_video_player.dart b/lib/hj_page/short_video/view/short_video_player.dart new file mode 100644 index 0000000..1e39747 --- /dev/null +++ b/lib/hj_page/short_video/view/short_video_player.dart @@ -0,0 +1,179 @@ +import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/short_video/view/float_ad_view.dart'; +import 'package:hgdj/hj_page/short_video/view/short_video_menu.dart'; +import 'package:hgdj/hj_page/short_video/view/short_video_player_logic.dart'; +import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart'; +import 'package:hgdj/hj_page/short_video/view/video_progress_widget.dart'; +import 'package:hgdj/hj_page/video/view/video_mask_buy_view.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:video_player/video_player.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import '../short_video_page.dart'; + +class ShortVideoPlayer extends StatefulWidget { + const ShortVideoPlayer({ + super.key, + this.isCurrentPage, + required this.index, + required this.videoInfo, + this.listType, + this.isScrolling, + }); + + final int index; + final VideoModel? videoInfo; + final bool? isCurrentPage; + final VideoListType? listType; + final ValueNotifier? isScrolling; // 上下滑动切视频时隐藏进度条 + + @override + State createState() => _ShortVideoPlayerState(); +} + +// PageView 里 2~3 条同时存活,用 per-实例唯一 tag 隔离各自的播放器 logic +class _ShortVideoPlayerState extends State + with UniqueTagMixin { + //进度条压暗档位:滑动中 / 挂着买量蒙层都用这一档 + static const _dimOpacity = 0.4; + + //压暗/复原的淡入淡出时长。滑动只在 ScrollStart/ScrollEnd 各通知一次, + //硬跳变正好压在手指刚动和刚松的瞬间,很容易被看成闪烁,给一小段过渡盖住 + static const _dimDuration = Duration(milliseconds: 150); + + late final ShortVideoPlayerLogic _logic = ShortVideoPlayerLogic( + index: widget.index, + videoInfo: widget.videoInfo, + listType: widget.listType, + ); + + @override + void didUpdateWidget(covariant ShortVideoPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + // 同 id 换对象(首条拉完详情后 removeAt+insert)时 State 会被复用,logic 必须跟着换 + _logic.videoInfo = widget.videoInfo; + _logic.index = widget.index; + // isCurrentPage 由父级每次 rebuild 传入,是流动属性,只能在这里比出「刚滑回本条」 + if (widget.isCurrentPage == true && oldWidget.isCurrentPage != true) { + _logic.onBecomeCurrent(); + } + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: _logic, + builder: (logic) { + final status = logic.videoStatus; + return VisibilityDetector( + key: logic.visibilityKey, + onVisibilityChanged: logic.onVisibility, + child: GestureDetector( + onTap: logic.togglePlay, + onLongPressUp: logic.resetSpeed, + onLongPressEnd: (_) => logic.resetSpeed(), + onLongPressStart: (_) => logic.speedUp(), + // 用 Material 而非 Scaffold:这里只需要一层透明底 + Material 语境, + // 每个 PageView item 各挂一个 Scaffold 太重 + child: Material( + color: Colors.transparent, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + if (logic.playerCtr?.value.isInitialized == true) + Center( + child: AspectRatio( + aspectRatio: logic.playerCtr!.value.aspectRatio, + child: VideoPlayer(logic.playerCtr!), + ), + ), + _bufferLoading(logic), + ShortVideoMenu( + videoModel: logic.video, + playerCtr: logic.playerCtr, + watchCount: logic.watchCount, + onBuy: logic.onBuy, + onSwitchLine: logic.onSwitchLine, + ), + FullScreenButton(logic: logic), + if (widget.isCurrentPage == true) const FloatAdView(), + if (status.isNeedPay) // vip用遮罩, 金币用弹窗 + Positioned.fill( + child: Visibility( + visible: logic.showBuyMask, + child: VideoMaskBuyView( + logic.video, + maskTitle: logic.maskTitle, + playCtr: logic.playerCtr, + onBuySucc: logic.onBuySuccess, + ), + ), + ), + // 进度条:每个播放器自管,绑定自身 playerCtr;买量蒙层时半透明,菜单显隐由组件内部跟随操作台 + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _progressBar(logic), + ), + ], + ), + ), + ), + ); + }, + ); + } + + /// 内嵌进度条。与短剧同一套写法:压暗态只有一档 40%,用**一个** AnimatedOpacity 表达—— + /// 上下滑动切视频时(留个淡影不至于整条凭空消失)、挂着买量蒙层时都压到 40%,带 150ms 淡入淡出。 + /// 无 isScrolling 信号则常显 + Widget _progressBar(ShortVideoPlayerLogic logic) { + //二级页进度条贴底,避让系统手势条(home indicator),否则 thumb 和横线重合难点中;其他场景不加 + final bottomSafe = widget.listType == VideoListType.SECOND + ? Get.mediaQuery.padding.bottom + 10 + : 0.0; + final Widget bar = VideoProgressWidget( + controller: logic.playerCtr, + padding: EdgeInsets.only(left: 12, right: 12, bottom: bottomSafe), + ); + final scrolling = widget.isScrolling; + if (scrolling == null) + return Opacity(opacity: logic.showBuyMask ? _dimOpacity : 1, child: bar); + return ValueListenableBuilder( + valueListenable: scrolling, + //只做透明+屏蔽点击,不能用 Visibility——它会卸载 child,重建时 initState 会拿旧 controller 挂监听 + builder: (_, isScroll, child) => IgnorePointer( + ignoring: isScroll, + child: AnimatedOpacity( + opacity: isScroll || logic.showBuyMask ? _dimOpacity : 1, + duration: _dimDuration, + child: child!, + ), + ), + child: bar, + ); + } + + /// 未就绪/缓冲中的居中转圈。直接监听 playerCtr(本身是 ValueNotifier), + /// 切视频/切线路换了控制器时 ValueListenableBuilder 会自动改订阅,无需手动 add/removeListener + Widget _bufferLoading(ShortVideoPlayerLogic logic) { + const indicator = Center( + child: + CupertinoActivityIndicator(color: AppColors.actionRed, radius: 15)); + final ctr = logic.playerCtr; + if (ctr == null) return indicator; // 控制器还没建好,同样转圈 + return ValueListenableBuilder( + valueListenable: ctr, + builder: (_, value, __) => !value.isInitialized || value.isBuffering + ? indicator + : const SizedBox(), + ); + } +} diff --git a/lib/hj_page/short_video/view/short_video_player_logic.dart b/lib/hj_page/short_video/view/short_video_player_logic.dart new file mode 100644 index 0000000..acd3764 --- /dev/null +++ b/lib/hj_page/short_video/view/short_video_player_logic.dart @@ -0,0 +1,180 @@ +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/short_video/view/video_player_base_logic.dart'; +import 'package:hgdj/tools_base/toast.dart'; + +import '../../../alert/video/buy_vip_alert.dart'; +import '../../../alert/vip_guide/guide_bottom_sheet.dart'; +import '../../../alert/vip_guide/guide_common.dart'; +import '../../../alert/vip_guide/guide_config.dart'; +import '../../../alert/vip_guide/guide_manager.dart'; +import '../../../alert/vip_guide/timed_popup_manager.dart'; +import '../../../hj_model/splash/watch_count_model.dart'; +import '../../../hj_utils/api_service/vid_service.dart'; +import '../../../hj_utils/free_play_manager.dart'; +import '../../../tools_base/debug_log.dart'; +import '../../pre_sale/pre_sale_provider.dart'; +import '../../video/view/long_video_status.dart'; +import '../short_video_page.dart'; + +/// 单条短视频的试看/付费逻辑。播放器本身的机制(建控制器、265 降级、可见性播停)在基类。 +/// PageView 每项一个实例,由页面用唯一 tag 隔离 +class ShortVideoPlayerLogic extends VideoPlayerBaseLogic { + ShortVideoPlayerLogic( + {required this.index, required this.videoInfo, this.listType}); + + /// index / videoInfo 会被页面同步刷新:列表可能用**同 id 的新对象**替换某条 + /// (如首条拉完详情后 removeAt+insert),PageView 按 id 做 key 会复用 State, + /// 这里不跟着换就会一直读旧 model —— 详情写进旧对象、权限判断也读旧数据 + int index; + VideoModel? videoInfo; + final VideoListType? listType; + + // ===== 付费/试看 ===== + bool showBuyMask = false; // vip用遮罩, 金币用弹窗 + String maskTitle = '试看结束'; + bool _payAheadShown = false; // 本条「播满3秒前置提示」是否已弹(避免重复) + WatchCount? watchCount; + + /// 当前该用的视频模型:SECOND 类型要用详情接口补齐权限后的那个对象 + @override + VideoModel? get video { + if (listType == VideoListType.SECOND && videoInfo?.detailVModel != null) { + return videoInfo?.detailVModel; + } + return videoInfo; + } + + @override + bool get isBlocked => showBuyMask; + + @override + String get debugTag => 'page $index'; + + LongVideoStatus get videoStatus => longVideoStatus(video); + + @override + void onInit() { + super.onInit(); + initPlayer(video); + _useFreeCount(); + if (listType == VideoListType.SECOND && videoInfo?.detailVModel == null) { + // 从列表页进入短视频,需要获取视频详情数据,来处理权限问题;(列表数据服务端为了速度,没有获取相关权限数据,前端单独获取) + _loadDetail(canRetry: true); + } + } + + /// 竖滑回到本条:进度归零重看(切 tab 回来要接着播,所以不能归零)。 + /// 蒙层在这里被清掉,必须重新求值播放,否则「可见性没再变化」时没人负责起播 + void onBecomeCurrent() { + showBuyMask = false; + resetCompleted(); + playerCtr?.seekTo(Duration.zero); + syncPlay(); + } + + Future _loadDetail({bool canRetry = false}) async { + // 先锁定本次的 model:await 期间 videoInfo 可能被页面换成同 id 的新对象, + // 那样就会「锁 A、解锁 B」,A 的 isLoadingDetail 永久残留 + final target = videoInfo; + if (target?.isLoadingDetail == true) return; // 同一 model 正在拉,别并发 + target?.isLoadingDetail = true; + try { + final detail = await VidService.getDetail(target?.id ?? ''); + if (detail != null) target?.detailVModel = detail; + } catch (e) { + debugLog(e.toString()); + // 必须先解锁再重试:锁挂在 model 上、跟着 model 活, + // 不解锁重试会被自己的守卫挡掉,且这条视频后续再也拉不到详情 + target?.isLoadingDetail = false; + if (canRetry) return _loadDetail(canRetry: false); + } + target?.isLoadingDetail = false; + } + + /// 消耗本条的免费观看/金币权益次数,并快照剩余次数给菜单展示 + void _useFreeCount() { + FreePlayManager().useFreePlay(video); + watchCount = FreePlayManager().remain?.copy(); + presaleProvider.useCoinPrivilege(video); + watchCount?.coinWatchCount = presaleProvider.coinVideoFreeCount; + } + + /// 每帧回调:试看到点拦截 + @override + void onTick(int position) { + if (video?.freeArea == true) return; + if (FreePlayManager().useFreePlay(video)) return; + // 历史记录已统一收到 ShortVideoLogic.onPageChanged:滑到即记,与播放器/权限/进度无关 + // vip用遮罩, 金币用弹窗;有金币免费观看次数时不再要求购买 + final status = videoStatus; + final isNeedBuy = + status.isNeedBuy && (watchCount?.coinWatchCount ?? -1) < 0; + if (!isNeedBuy && !status.isNeedVip) return; + _showPayAhead(); // 场景1(短):under7/over7 播满3秒的前置提示 + if (position < (video?.freeTime ?? 10)) return; + if (playerCtr?.value.isPlaying != true) return; + playerCtr?.pause(); + if (listType == VideoListType.SECOND && videoInfo?.detailVModel == null) { + showToast("权限校验失败"); + _loadDetail(); + return; + } + maskTitle = '试看结束'; + showBuyMask = true; + notify(); + // 与长视频对齐:VIP 片在蒙层之上叠开通弹窗;金币片只留蒙层,由蒙层引导金币解锁/观影券 + // 不走 TimedPopupManager:这是硬拦截,被互斥挡下就等于没弹 + if (status.isNeedVip && !isClosed) BuyVipAlert.show(videoId: video?.id); + } + + /// 起播引导(VIDEO_PREVIEW_END):需付费的短视频播满 3 秒前置弹一次购买引导(暂停,关掉后续播,每条一次)。 + /// 走播放进度而非墙钟计时:滑走即 pause、position 停住,天然不会在非当前页误触发。 + Future _showPayAhead() async { + if (_payAheadShown) return; + if (playerCtr?.value.isPlaying != true) return; + if ((video?.freeTime ?? 10) < 3) return; // 不足3秒:没等到提示就已被试看结束拦截了 + if ((playerCtr?.value.position.inSeconds ?? 0) < 3) return; + if (!_canShowGuide) return; + _payAheadShown = true; // await 前置位,防 listener 每帧重入 + // 先备好卡数据再暂停,否则拉数据期间视频白停几百毫秒;场景没配卡就压根不弹,别白暂停一下 + if (await loadGuideCard(GuideScene.videoPreviewEnd) == null) return; + if (isClosed || !isVisible) return; // 拉数据期间滑走/切走了就别弹了 + // freeTime 恰好是3秒时,本引导和试看结束会在同一帧触发,蒙层挂着就别再叠一个弹窗 + // (试看结束改走 BuyVipAlert 后不再经 TimedPopupManager,互斥挡不住这种情况) + if (showBuyMask) return; + playerCtr?.pause(); + bool goVip = false; + await TimedPopupManager().trigger( + canShow: () => _canShowGuide, + //短视频按钮无价文案;长视频才用默认的「立即开通 · 仅需¥价格」 + onShow: () async => + goVip = await GuideBottomSheet.showVideo(buttonText: '开通会员 · 查看完整内容'), + ); + // 跳了会员页就不恢复播放;滑到别条 / 期间被试看结束拦下弹了蒙层,由 syncPlay 兜住 + if (goVip || isClosed) return; + syncPlay(); + } + + /// 起播前置引导开关是否允许弹(后端下发的 enabled && show)。 + /// 试看结束不再走这个开关——那里是 BuyVipAlert 硬拦截,与长视频一致 + bool get _canShowGuide => GuideManager().canShow(GuideScene.videoPreviewEnd); + + /// 菜单点购买:暂停并弹解锁蒙层 + void onBuy() { + final status = videoStatus; + if (!status.isNeedPay) return; + playerCtr?.pause(); + maskTitle = '解锁完整视频'; + showBuyMask = true; + notify(); + } + + /// 蒙层内购买成功:撤蒙层并按可见性决定要不要续播 + void onBuySuccess() { + showBuyMask = false; + video?.vidStatus?.hasPaid = true; + // 可能是在充值页付完才回调,页面还没露出来就别出声,等切回来由可见性接着播 + syncPlay(); + notify(); + } +} diff --git a/lib/hj_page/short_video/view/video_menu_kit.dart b/lib/hj_page/short_video/view/video_menu_kit.dart new file mode 100644 index 0000000..ce739c2 --- /dev/null +++ b/lib/hj_page/short_video/view/video_menu_kit.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/short_video/view/video_player_base_logic.dart'; +import 'package:hgdj/hj_page/video/video_full_page.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:video_player/video_player.dart'; + +typedef MenuSwitchCallback = void Function(bool); // true 显示 + +/// 操作台(左右菜单 + 进度条)的显隐开关,全局一份: +/// 短视频和短剧各有各的操作台,但「把界面收起来」是同一个用户意图,状态必须共享 +class VideoMenuSwitch { + VideoMenuSwitch._(); + + static bool isShow = true; + + static final List _listeners = []; + + static void addListen(MenuSwitchCallback callback) => + _listeners.add(callback); + + static void removeLister(MenuSwitchCallback callback) => + _listeners.remove(callback); + + static void toggle() { + isShow = !isShow; + notifyListeners(); + } + + //遍历副本,避免回调中增删监听导致并发修改 + static void notifyListeners() { + for (final callback in List.of(_listeners)) { + callback(isShow); + } + } +} + +/// 操作台右侧的动作项:图标 + 文案,图标切换(点赞/收藏选中态)带缩放淡入 +Widget menuActionItem(String title, String imgPath, + {GestureTapCallback? onTap}) { + return InkWell( + enableFeedback: false, + onTap: onTap, + child: Column( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + transitionBuilder: (child, animation) => ScaleTransition( + scale: animation, + child: FadeTransition(opacity: animation, child: child), + ), + child: Image.asset( + imgPath, + key: ValueKey(imgPath), + width: 30, + height: 30, + ), + ), + //空文案(眼睛按钮)不占位,否则底下多一行行高、和左侧信息栏对不齐; + //FittedBox 让线路名之类的长文案缩成一行,别撑成两行把菜单项间距顶乱 + if (title.isNotEmpty) + FittedBox( + child: Text(title, + style: const TextStyle( + color: Color(0xffDCDCDC), + fontSize: 11, + fontWeight: FontWeight.w500)), + ), + ], + ), + ); +} + +/// 操作台显隐过渡:淡入淡出 + 横向滑动。 +/// 隐藏时保留占位(避免布局跳动)且 IgnorePointer 不拦截点击(让点击穿透到视频)。 +class MenuReveal extends StatelessWidget { + final bool visible; + final Offset offset; // 隐藏时的位移(相对自身尺寸):右侧菜单 (0.35,0) 右滑、左侧信息栏 (-0.1,0) 左滑 + final Widget child; + + const MenuReveal({ + super.key, + required this.visible, + required this.child, + this.offset = const Offset(0, 0.1), + }); + + @override + Widget build(BuildContext context) { + const duration = Duration(milliseconds: 250); + return IgnorePointer( + ignoring: !visible, + child: AnimatedSlide( + duration: duration, + curve: Curves.easeOut, + offset: visible ? Offset.zero : offset, + child: AnimatedOpacity( + duration: duration, + curve: Curves.easeOut, + opacity: visible ? 1 : 0, + child: child, + ), + ), + ); + } +} + +/// 中间播放/暂停指示:暂停时显示三角、播放时淡出,切换做形变过渡。 +/// 不拦截点击,交给底层视频手势,避免双重触发 +class CenterPlayPause extends StatefulWidget { + final VideoPlayerController controller; + const CenterPlayPause({super.key, required this.controller}); + + @override + State createState() => CenterPlayPauseState(); +} + +class CenterPlayPauseState extends State + with SingleTickerProviderStateMixin { + // play_pause 形变进度:0=播放三角,1=暂停双杠 + late final AnimationController _morphCtr = AnimationController( + duration: const Duration(milliseconds: 260), + vsync: this, + ); + bool _isPlaying = false; + bool _isInited = false; + + @override + void initState() { + super.initState(); + _sync(initial: true); + widget.controller.addListener(_onValue); + } + + void _onValue() { + final v = widget.controller.value; + // 仅在播放态/初始化态真正变化时刷新,避免每帧 position 回调触发 setState + if (v.isPlaying != _isPlaying || v.isInitialized != _isInited) { + _sync(); + } + } + + void _sync({bool initial = false}) { + final v = widget.controller.value; + _isPlaying = v.isPlaying; + _isInited = v.isInitialized; + if (initial) { + _morphCtr.value = _isPlaying ? 1 : 0; // 初始无动画直接定位 + } else { + _isPlaying ? _morphCtr.forward() : _morphCtr.reverse(); // 切换走形变 + } + if (mounted) setState(() {}); + } + + @override + void didUpdateWidget(covariant CenterPlayPause oldWidget) { + super.didUpdateWidget(oldWidget); + // 切线路/复用:controller 变了,迁移监听并按新状态重定位 + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onValue); + widget.controller.addListener(_onValue); + _sync(initial: true); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onValue); + _morphCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bool show = _isInited && !_isPlaying; // 暂停时显示 + return IgnorePointer( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOut, + opacity: show ? 1 : 0, + child: AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: _morphCtr, + size: 56, + color: Colors.white.withValues(alpha: 0.95), + ), + ), + ); + } +} + +/// 横向视频(direction=横 / 宽高比>1)才露出的转全屏按钮,短视频和短剧共用。 +/// 按钮位置跟着宽高比走:越宽的视频画面越矮,按钮要跟着往上收 +class FullScreenButton extends StatelessWidget { + final VideoPlayerBaseLogic logic; + + const FullScreenButton({super.key, required this.logic}); + + @override + Widget build(BuildContext context) { + final ratio = (logic.playerCtr?.value.aspectRatio ?? 1) - 1; + return Visibility( + visible: logic.isWide, + child: Align( + alignment: Alignment(0.0, ratio / 4 + 0.05), + child: Padding( + padding: const EdgeInsets.only(top: 100), + child: InkWell( + enableFeedback: false, + onTap: () async { + // 全屏页复用同一个 playerCtr,本页被盖住报不可见时不能跟着暂停 + logic.isFullScreen = true; + await Get.to(() => VideoFullPage(playCtr: logic.playerCtr!)); + logic.isFullScreen = false; + }, + //胶囊:半透明黑底 + 40% 白描边,14px 图标 + 「全屏观看」 + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.1), + border: Border.all(color: Colors.white.withValues(alpha: 0.4)), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('full_screen.webp'.videoPath, + width: 14, height: 14), + 4.sizeBoxW, + const Text('全屏观看', + style: TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/short_video/view/video_player_base_logic.dart b/lib/hj_page/short_video/view/video_player_base_logic.dart new file mode 100644 index 0000000..75b7d94 --- /dev/null +++ b/lib/hj_page/short_video/view/video_player_base_logic.dart @@ -0,0 +1,255 @@ +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/codec_support.dart'; +import 'package:hgdj/hj_utils/play_diagnose.dart'; +import 'package:hgdj/hj_utils/video_view_type.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:video_player/video_player.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import '../../../tools_base/debug_log.dart'; + +/// 竖屏播放器的**播放器管线**:建控制器、H.265 降级、可见性驱动播停、倍速、完播检测。 +/// 只放与业务无关的机制——试看拦截、付费墙、续播这些各自在子类里做。 +/// 短视频([ShortVideoPlayerLogic])和短剧([DramaVideoPlayerLogic])共用它,但互不知道对方存在 +abstract class VideoPlayerBaseLogic extends GetxController { + // ===== 全局暂停广播 ===== + // 播放由各播放器按自身可见性自理,这里只广播「全都停下」 + // (拔耳机/来电/跳外部页等与可见性无关的场景) + static final List _pauseListeners = []; + + static void pauseAll() { + for (final callback in [..._pauseListeners]) { + callback(); + } + } + + // ===== 播放器 ===== + VideoPlayerController? playerCtr; + bool isWide = false; // 横屏视频(宽高比>1.3),露出转全屏按钮 + String? _playUrl; // 当前播放 url + bool _forceH264 = false; // 本条是否已回退 264:初始化失败/播放中报错都置位(仅内存;新条=新 logic 自动复位) + + /// 当前是否在播 H.265 源。**必须选流当下记录**,不能用 `_playUrl == realH265Url` 派生—— + /// token/cdn 刷新、详情异步换对象,都会让派生结果误判成 false,265 降级链就失效了 + bool _is265 = false; + + // ===== 播放开关 ===== + /// 本条是否真的显示在屏幕上——播/停的唯一依据。底部切 tab、竖滑到别条、 + /// 被别的路由盖住,都会让它变 false,不用再各处广播播放状态 + bool _isVisible = false; + + bool get isVisible => _isVisible; + + /// 全屏页复用同一个 playerCtr,进全屏时本页被路由盖住会报不可见,此时不能跟着暂停 + bool isFullScreen = false; + + /// 同一条视频可能同时挂在多个 tab 上,用实例级 key 保证全局唯一 + final visibilityKey = UniqueKey(); + + bool _completedSent = false; // 本次播放是否已回调过完播 + + // ===== 子类要给的 ===== + + /// 当前该播的模型 + VideoModel? get video; + + /// 有遮罩挂着不该播(试看结束 / 付费墙) + bool get isBlocked; + + /// 播放进度回调,已过滤掉销毁和 265 报错。[position] 当前秒数。 + /// **不是每帧**:video_player 只在播放中挂一个 500ms 的 Timer 拉 position(暂停即 cancel), + /// 稳态就是 2 次/秒;另外 value 有变化(缓冲/播停/尺寸)时会顺带多触发几次 + void onTick(int position); + + /// 本条播完,每次播放只回一次 + void onFinished() {} + + /// 日志用的标识,子类给个下标就行 + String get debugTag; + + @override + void onInit() { + super.onInit(); + _pauseListeners.add(onPauseAll); + } + + @override + void onClose() { + _pauseListeners.remove(onPauseAll); + // 库只在「变不可见」时清缓存,仍可见就被销毁(整页退出)的那几条会残留,这里兜底 + VisibilityDetectorController.instance.forget(visibilityKey); + playerCtr?.removeListener(_onProgress); + playerCtr?.dispose(); + //必须置空:销毁后 widget 树可能还会重建一帧(PageView 卸载/Visibility 复位), + //拿到已 dispose 的控制器再 addListener 就是 "used after being disposed" + playerCtr = null; + debugLog("dispose--> $debugTag"); + super.onClose(); + } + + /// 全局静音场景(拔耳机/来电/跳外部页):只负责停,恢复播放仍由可见性决定。 + /// 子类要在停之前做事(如落盘进度)就覆写它,记得调 super + @protected + void onPauseAll() => playerCtr?.pause(); + + /// 可见性变化:更新状态后重新求值播放 + void onVisibility(VisibilityInfo info) { + // 阈值取 0.5:竖滑时前后两条占比之和为 1,只可能有一条过半,天然不会两条同时播; + // 也不怕机型安全区/布局把播放器裁掉一点(用 0.9 会因此永远播不起来) + final visible = info.visibleFraction > 0.5; + if (_isVisible == visible) return; + _isVisible = visible; + syncPlay(); + } + + /// 播/停的唯一出口:可见且没挂遮罩才播,不可见就停。 + /// 凡是改了可见性 / [isBlocked] 的地方都要调一次——只在可见性回调里判, + /// 遮罩被别处清掉(如滑回本条)时就没人负责重新起播了 + void syncPlay() { + // 销毁后仍会被调到:VisibilityDetector 卸载时补一次 0 回调、initialize/seekTo 的 await 也可能跨过销毁, + // 那时 playerCtr 已在 onClose() 里释放,再 play/pause 就是用已释放的控制器 + if (isClosed) return; + if (isFullScreen) return; // 全屏中,播放归全屏页管 + if (!_isVisible) { + playerCtr?.pause(); + debugLog("page====pause:$debugTag"); + return; + } + if (isBlocked) return; + playerCtr?.play(); + debugLog("page====play:$debugTag"); + } + + /// 异步回调里刷新 UI,统一挡一道销毁。 + /// 不能叫 refresh()——GetX 的 update() 无参时内部就是调 refresh(),覆写它会自递归成 Stack Overflow + void notify() { + if (!isClosed) update(); + } + + /// 滑回本条重看:完播回调重新可触发 + @protected + void resetCompleted() => _completedSent = false; + + /// CDN 路线切换:同视频换 URL 重新起播 + void onSwitchLine() { + initPlayer(video); + notify(); + } + + /// [model] 显式传入而不读 getter:失败重试链要一路用同一个 model + Future initPlayer(VideoModel? model, {bool isRetry = false}) async { + if (isClosed) return; // 已销毁(快速滑走/失败重试排队中),别再建控制器,否则没人释放 + VideoPlayerController? preVideoCtr = + playerCtr; // 旧控制器,切线路/失败后需释放(声明在 try 外,catch 才能拿到) + try { + preVideoCtr?.pause(); + int prePosition = preVideoCtr?.value.position.inSeconds ?? 0; + // 只用主源:设备硬解支持 265、未回退、有 265 地址 → 265,否则 264(realVideoUrl) + final h265 = model?.realH265Url ?? ''; + final use265 = CodecSupport.useH265 && !_forceH264 && h265.isNotEmpty; + _playUrl = use265 ? h265 : model?.realVideoUrl; + _is265 = use265; // 选流结果直接记录,后续 token/cdn 变化不影响判定 + debugLog("竖屏播放 url(${use265 ? 'H265' : 'H264'}):$_playUrl"); + playerCtr = PlayerFactory.network(_playUrl); // 仅网络播放,不走本地文件 + notify(); + await playerCtr?.initialize(); + if (isRetry) + confirmPlatformView(); // 仅重试成功(同视频 textureView 挂、platformView 放出)才落本地 + preVideoCtr?.removeListener(_onProgress); + preVideoCtr?.dispose(); // 切线路/刷新:释放旧控制器,避免解码器泄漏累积导致后续视频全部加载失败 + // initialize 期间可能已被销毁(快速连滑),playerCtr 已在 onClose() 里释放, + // 再往上挂监听/起播就是在用已释放的控制器 + if (isClosed) return; + playerCtr?.addListener(_onProgress); + isWide = (playerCtr?.value.aspectRatio ?? 1) > 1.3; + // 初始化是异步的,期间可能已经滑走/切走,只在仍显示在屏幕上时才起播 + if (_isVisible) { + if (prePosition > 1) { + await playerCtr?.seekTo(Duration(seconds: prePosition)); + } + syncPlay(); + } + } catch (e) { + preVideoCtr?.dispose(); // 释放进入本次时的旧控制器 + debugLog("视频初始化失败:$e play url = $_playUrl"); + if (isDecoderError(e)) { + // 1) 先按芯片兼容机制切 platformView 重试(保留硬解/265,海思等渲染错常能救回) + if (switchToPlatformView()) { + initPlayer(model, isRetry: true); + return; + } + // 2) platformView 也救不了且当前放的是 265:本机永久禁用 265 并落本地,用 264 重试 + if (_is265 && CodecSupport.disableForDevice()) { + initPlayer(model, isRetry: true); + return; + } + } + // 3) 非解码类错误(404/域名不通/非法 m3u8):只是这条 265 资源坏,与芯片无关 → 仅本条回退 264。 + // isRetry 透传而非写死 true:没切过 platformView 就别让 264 播通了去误标它 + if (_is265 && !_forceH264) { + _forceH264 = true; + debugLog("H.265 资源不可用,回退 264 兜底重试"); + initPlayer(model, isRetry: isRetry); + return; + } + PlayDiagnose.report( + scene: '竖屏播放失败($debugTag)', + error: e, + url: _playUrl, + videoId: model?.id, + videoTitle: model?.title, + isH265: _is265, + forceH264: _forceH264, + ); + if (!isClosed) showToast("视频资源加载失败"); // 已滑走就别再弹了 + } + notify(); + } + + /// 265 运行时播放器报错:本条回退 264 重播,不持久化(下条/重启仍试 265)。 + /// 重播后 _playUrl 变 264 → _is265 为 false,`_forceH264` 守卫防重建期间重入 + void _fallbackTo264() { + if (isClosed || !_is265 || _forceH264) return; + _forceH264 = true; + debugLog("H.265 运行时报错,回退 264 重播"); + initPlayer(video); + } + + /// 播放器进度回调(约 2 次/秒,见 onTick 注释):265 报错回退 → 完播回调 → 交给子类 + void _onProgress() { + if (isClosed) return; + final ctr = playerCtr; + if (ctr == null) return; + if (_is265 && ctr.value.hasError) { + _fallbackTo264(); + return; + } + final position = ctr.value.position.inSeconds; + if (position >= ctr.value.duration.inSeconds - 1) { + // duration 为 0 的异常流会让上面的条件恒成立,这里挡一道,别刚起播就当播完 + if (!_completedSent && ctr.value.duration.inSeconds > 0) { + _completedSent = true; + onFinished(); + } + } + onTick(position); + } + + /// 点屏播放/暂停。返回操作前是否在播(子类要据此落进度) + bool togglePlay() { + final isPlaying = playerCtr?.value.isPlaying == true; + isPlaying ? playerCtr?.pause() : playerCtr?.play(); + return isPlaying; + } + + /// 长按 2 倍速 + void speedUp() { + showToast("长按不动,2X倍速播放"); + playerCtr?.setPlaybackSpeed(2.0); + } + + /// 长按结束/抬手都要恢复常速 + void resetSpeed() => playerCtr?.setPlaybackSpeed(1.0); +} diff --git a/lib/hj_page/short_video/view/video_progress_widget.dart b/lib/hj_page/short_video/view/video_progress_widget.dart new file mode 100644 index 0000000..6d4e549 --- /dev/null +++ b/lib/hj_page/short_video/view/video_progress_widget.dart @@ -0,0 +1,363 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:hgdj/hj_page/short_video/view/video_menu_kit.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:video_player/video_player.dart'; + +const double _kSliderThumbRadius = 6; //滑杆圆点外圈半径(按设计稿外圈直径12;轨道两端按此内缩,避免压到时间文字) + +//视频进度条:当前时间 + 原生 Slider 滑杆 + 总时长(拖动/点击由框架处理,手感更跟手) +class VideoProgressWidget extends StatefulWidget { + final EdgeInsets? padding; + final VideoPlayerController? controller; + final Function(bool)? gestureCallback; // true 开始, false 结束 + final Function(Duration?)? skipCallback; + + /// 外部拖动预览位置(如播放器整屏横滑时传入):非空时进度条 thumb/时间据它显示 + final Duration? previewSeek; + + /// 试看场景下松手回弹边界:超出 [minSeekDuration, maxSeekDuration] 时回到边界 + final Duration? minSeekDuration; + final Duration? maxSeekDuration; + + /// 是否允许拖动进度(试看结束锁定时传 false,禁止拖动) + final bool enableSeek; + + const VideoProgressWidget({ + super.key, + this.padding, + this.controller, + this.gestureCallback, + this.skipCallback, + this.previewSeek, + this.minSeekDuration, + this.maxSeekDuration, + this.enableSeek = true, + }); + + @override + State createState() => _VideoProgressWidgetState(); +} + +class _VideoProgressWidgetState extends State { + VideoPlayerController? playerCtr; + + Duration? _seekPos; //本组件滑杆拖动中的目标进度(非空时进度条按它显示,松手后清空) + bool _isShowMenu = true; //操作台是否显示(跟随操作台显隐) + + String? _timeKey; //上次实测对应的总时长格式串 + double _timeWidth = 0; //缓存的时间文本宽度 + + //显示用进度:本组件拖动优先,其次外部横滑预览 + Duration? get _displaySeek => _seekPos ?? widget.previewSeek; + + //当前进度比例 0~1 + double get _progress { + final totalMs = playerCtr?.value.duration.inMilliseconds ?? 0; + if (totalMs <= 0) return 0; + final curMs = (_displaySeek ?? playerCtr!.value.position).inMilliseconds; + return curMs < 0 ? 0 : curMs / totalMs; + } + + @override + void initState() { + super.initState(); + VideoMenuSwitch.addListen(_onMenuSwitch); + playerCtr = widget.controller; + playerCtr?.addListener(_onTick); + } + + @override + void didUpdateWidget(covariant VideoProgressWidget oldWidget) { + super.didUpdateWidget(oldWidget); + //构造参数传入的 controller 变化时迁移监听(短视频切线路/列表项复用、长视频换集都走这里) + if (widget.controller != null && widget.controller != playerCtr) { + playerCtr?.removeListener(_onTick); + playerCtr = widget.controller; + playerCtr?.addListener(_onTick); + } + } + + @override + void dispose() { + playerCtr?.removeListener(_onTick); + VideoMenuSwitch.removeLister(_onMenuSwitch); + super.dispose(); + } + + //操作台显隐回调:进度条跟随操作台一起显示/隐藏 + void _onMenuSwitch(bool isShow) { + _isShowMenu = isShow; + _rebuild(); + } + + void _onTick() => _rebuild(); + + //controller 监听可能在 build/layout 阶段触发,此时直接 setState 会抛 "setState during build"; + //非空闲帧先等当前帧结束再刷新 + Future _rebuild() async { + if (!mounted) return; + if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.idle) { + await SchedulerBinding.instance.endOfFrame; + if (!mounted) return; + } + setState(() {}); + } + + //复用 DateTimeUtil 的 MM:SS / HH:MM:SS 格式化; + //forceHour: 总时长有小时位时,已播放时间不足 1 小时也补 "00:" 对齐格式(如 00:00:01) + String _formatTime(int seconds, {bool forceHour = false}) { + final text = DateTimeUtil.formatDuration(Duration(seconds: seconds)) ?? ""; + return (forceHour && seconds < 3600) ? "00:$text" : text; + } + + //限制在 [0, 总时长] 内 + Duration _clampSeek(Duration duration) { + var ms = duration.inMilliseconds; + if (ms < 0) ms = 0; + final totalMs = playerCtr?.value.duration.inMilliseconds ?? 0; + if (totalMs > 0 && ms > totalMs) ms = totalMs; + return Duration(milliseconds: ms); + } + + /// 松手时:超出试看区间则回弹到区间边界(超出末尾 → 试看结束点) + Duration _snapSeekOnRelease(Duration duration) { + final result = _clampSeek(duration); + final minMs = widget.minSeekDuration?.inMilliseconds; + final maxMs = widget.maxSeekDuration?.inMilliseconds; + if (minMs != null && result.inMilliseconds < minMs) + return widget.minSeekDuration!; + if (maxMs != null && result.inMilliseconds > maxMs) + return widget.maxSeekDuration!; + return result; + } + + //拖动开始:暂停由外部 gestureCallback 处理 + void _onSeekStart(double _) { + if (playerCtr?.value.isInitialized != true) return; + widget.gestureCallback?.call(true); + } + + //拖动中:更新预览进度 + 回调外部(中间时间提示) + void _onSeekUpdate(double v) { + final dur = playerCtr?.value.duration; + if (dur == null) return; + _seekPos = _clampSeek(dur * v); + widget.skipCallback?.call(_seekPos); + setState(() {}); + } + + //松手:回弹边界 → seekTo → 延迟清预览 → 通知结束 + Future _onSeekEnd(double v) async { + if (playerCtr?.value.isInitialized != true) return; + final target = _snapSeekOnRelease(playerCtr!.value.duration * v); + _seekPos = target; + try { + await playerCtr?.seekTo(target); + } catch (e) { + debugLog("seekTo:$e"); + } + if (!mounted) return; //await 期间可能已退出/销毁,避免回调到已 dispose 的父组件 + Future.delayed(const Duration(milliseconds: 500), () { + _seekPos = null; + }); + widget.gestureCallback?.call(false); + } + + @override + Widget build(BuildContext context) { + //操作台隐藏时:淡出 + 向下滑出,并 IgnorePointer 禁止拖动;无 controller 时透明占位(保留布局,避免初始化前后跳动) + const duration = Duration(milliseconds: 250); + return IgnorePointer( + ignoring: !_isShowMenu, + child: AnimatedSlide( + duration: duration, + curve: Curves.easeOut, + offset: _isShowMenu ? Offset.zero : const Offset(0, 0.6), + child: AnimatedOpacity( + duration: duration, + curve: Curves.easeOut, + opacity: _isShowMenu ? 1 : 0, + child: Opacity( + opacity: playerCtr == null ? 0 : 1, + child: Padding( + padding: widget.padding ?? EdgeInsets.zero, + child: _buildProgress(), + ), + ), + ), + ), + ); + } + + Widget _buildProgress() { + final totalSeconds = playerCtr?.value.duration.inSeconds ?? 0; + final forceHour = totalSeconds >= 3600; //总时长超 1 小时,已播放时间也统一带小时位 + final totalText = _formatTime(totalSeconds); + //宽度只随总时长格式串变化(视频时长基本恒定),缓存避免每帧 new TextPainter + if (totalText != _timeKey) { + _timeKey = totalText; + _timeWidth = _measureWidth(totalText); + } + final curSeconds = + _displaySeek?.inSeconds ?? playerCtr?.value.position.inSeconds ?? 0; + return Row( + children: [ + _timeText(curSeconds, + alignment: Alignment.centerRight, forceHour: forceHour), + 3.sizeBoxW, + Expanded(child: _buildSlider()), + 3.sizeBoxW, + _timeText(totalSeconds, + alignment: Alignment.centerLeft, forceHour: forceHour), + ], + ); + } + + //实测时间文本宽度(按总时长格式串),带上系统字体缩放避免折行,向上取整 +1 防边界裁剪 + //style 要和 _timeText 里渲染用的一致,否则量出来的宽度对不上 + double _measureWidth(String text) { + final painter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()]), + ), + textDirection: TextDirection.ltr, + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + return painter.width.ceilToDouble() + 1; + } + + //左侧时间右对齐、右侧时间左对齐,整体向滑杆靠拢。 + //tabularFigures 必须要:宽度是拿总时长那串量的,两边共用;iOS 系统字体数字是比例宽度 + //(0 比 1 宽不少),00:05 会比 16:31 宽而撑出 SizedBox,被最左端的圆点压住。 + //等宽数字后所有时间串同宽,顺带消掉秒数跳动时的左右抖动 + Widget _timeText(int seconds, + {required Alignment alignment, bool forceHour = false}) => + SizedBox( + width: _timeWidth, + child: Align( + alignment: alignment, + child: Text( + _formatTime(seconds, forceHour: forceHour), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()]), + maxLines: 1, + softWrap: false, + ), + ), + ); + + Widget _buildSlider() { + return SizedBox( + height: 28, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + overlayShape: SliderComponentShape.noOverlay, + trackShape: _VideoTrackShape(), + thumbShape: const _VideoThumbShape(), + ), + //onChanged 为 null 时 Slider 自动禁用(试看锁定) + child: Slider( + min: 0, + max: 1, + value: _progress.clamp(0.0, 1.0), + onChangeStart: widget.enableSeek ? _onSeekStart : null, + onChanged: widget.enableSeek ? _onSeekUpdate : null, + onChangeEnd: widget.enableSeek ? _onSeekEnd : null, + ), + ), + ); + } +} + +//进度轨道:左侧已播放=白色,右侧未播放=半透明白(视频上更清晰) +class _VideoTrackShape extends SliderTrackShape { + static final _active = Paint()..color = Colors.white; + static final _inactive = Paint()..color = Colors.white.withValues(alpha: 0.3); + + @override + Rect getPreferredRect({ + bool isDiscrete = false, + bool isEnabled = false, + Offset offset = Offset.zero, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + }) { + final h = sliderTheme.trackHeight ?? 2; + final top = offset.dy + (parentBox.size.height - h) / 2; + //左右各内缩一个圆点半径,thumb 到达两端时不越界压到时间文字 + final w = parentBox.size.width - _kSliderThumbRadius * 2; + return Rect.fromLTWH( + offset.dx + _kSliderThumbRadius, top, w > 0 ? w : 0, h); + } + + @override + void paint( + PaintingContext context, + Offset offset, { + required Animation enableAnimation, + bool isDiscrete = false, + bool isEnabled = false, + required RenderBox parentBox, + Offset? secondaryOffset, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required Offset thumbCenter, + }) { + final rect = getPreferredRect( + parentBox: parentBox, sliderTheme: sliderTheme, offset: offset); + final canvas = context.canvas; + //整条轨道裁成圆角(高度一半=胶囊),内部两段平涂:外侧两端圆角、thumb 处接缝平齐 + canvas.save(); + canvas.clipRRect( + RRect.fromRectAndRadius(rect, Radius.circular(rect.height / 2))); + canvas.drawRect( + Rect.fromLTRB(rect.left, rect.top, thumbCenter.dx, rect.bottom), + _active); + canvas.drawRect( + Rect.fromLTRB(thumbCenter.dx, rect.top, rect.right, rect.bottom), + _inactive); + canvas.restore(); + } +} + +//圆形 thumb:外圈白@50%(视频上显灰)、内圈白@100%(按设计稿外圈12/内圈10) +class _VideoThumbShape extends SliderComponentShape { + static final _outer = Paint()..color = Colors.white.withValues(alpha: 0.5); + static final _inner = Paint()..color = Colors.white; + + const _VideoThumbShape(); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => + Size.fromRadius(_kSliderThumbRadius); + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required Size sizeWithOverflow, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double textScaleFactor, + required double value, + }) { + final canvas = context.canvas; + canvas.drawCircle(center, _kSliderThumbRadius, _outer); //外圈 r6 白@50% + canvas.drawCircle(center, _kSliderThumbRadius - 1.5, _inner); //内圈 r5 白 + } +} diff --git a/lib/hj_page/splash/splash_ad_view.dart b/lib/hj_page/splash/splash_ad_view.dart new file mode 100644 index 0000000..c15e637 --- /dev/null +++ b/lib/hj_page/splash/splash_ad_view.dart @@ -0,0 +1,256 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_utils/screen.dart'; + +import '../../hj_model/splash/ads_model.dart'; +import '../../routers/jump_router.dart'; +import '../../tools_base/banner/ads_banner_widget.dart'; +import '../../tools_base/banner/ads_item.dart'; +import '../../tools_base/widget/net_image_widget.dart'; + +/// 启动页广告:3 秒倒计时后右上角按钮变为"关闭",点击触发 callback 跳首页 +class SplashAdView extends StatefulWidget { + final List? adData; + final Function? callback; + + const SplashAdView({ + super.key, + this.adData, + this.callback, + }); + + @override + State createState() => _SplashAdViewState(); +} + +class _SplashAdViewState extends State { + int count = 3; + Timer? timer; + + @override + void initState() { + super.initState(); + timer = Timer.periodic(const Duration(seconds: 1), (t) { + count--; + setState(() {}); + if (count <= 0) t.cancel(); + }); + } + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + _buildAdContent(), + SafeArea( + child: Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(top: 28, right: 17), + child: _buildCountdownButton(), + ), + ), + ), + ], + ); + } + + /// 三种展示形态:1 张走单图点击跳转,多张走轮播组件,无广告则显示默认启动图 + Widget _buildAdContent() { + final dataLen = widget.adData?.length ?? 0; + if (dataLen == 1) { + final ad = widget.adData!.first; + return GestureDetector( + onTap: () => pushToPageByLink(ad.href ?? ""), + child: Container( + alignment: Alignment.center, + child: NetworkImageLoader( + imageUrl: ad.cover ?? "", + width: screen.screenWidth, + height: screen.screenHeight, + fit: BoxFit.cover, + placeHolderWidget: _splashBg(), + ), + ), + ); + } + if (dataLen > 1) { + return Container( + color: Color(0xff151515), + child: SplashBannerWidget(adData: widget.adData ?? []), + ); + } + return _splashBg(); + } + + Widget _splashBg() => Image.asset( + 'ic_splash_bg.webp'.launchPath, + fit: BoxFit.fill, + width: Get.width, + height: Get.height, + ); + + Widget _buildCountdownButton() { + return GestureDetector( + // 倒计时未结束时点击无效,避免用户误触跳过广告 + onTap: () { + if (count <= 0) widget.callback?.call(); + }, + child: Container( + height: 36, + width: 77, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(18), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, anim) { + // 倒计时递减:新数字从上滑入、旧数字向下滑出(下翻效果) + final isIncoming = child.key == ValueKey(count); + final begin = isIncoming ? const Offset(0, -1) : const Offset(0, 1); + return ClipRect( + child: SlideTransition( + position: + Tween(begin: begin, end: Offset.zero).animate(anim), + child: child, + ), + ); + }, + child: count > 0 + ? Text( + count.toString(), + key: ValueKey(count), + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ) + : const Text( + "关闭", + key: ValueKey(0), + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + ), + ); + } +} + +/// 启动页多广告轮播:2.5 秒自动切换,底部圆点指示器,左右双向无限滑 +class SplashBannerWidget extends StatefulWidget { + final List adData; + + const SplashBannerWidget({ + super.key, + required this.adData, + }); + + @override + State createState() => _SplashBannerWidgetState(); +} + +class _SplashBannerWidgetState extends State { + late final PageController _pageCtr; + Timer? _timer; + + List get _ads => widget.adData; + int get _len => _ads.length; + + @override + void initState() { + super.initState(); + _pageCtr = PageController(initialPage: 0); + _pageCtr.addListener(_onPageScroll); + // 延迟启动自动轮播,避免和入场动画冲突;mounted 检查防止 dispose 后启动 timer + if (_len > 1) { + Future.delayed(const Duration(seconds: 1), () { + if (!mounted) return; + _timer = Timer.periodic(const Duration(milliseconds: 2500), _onTimer); + }); + } + } + + @override + void dispose() { + _timer?.cancel(); + _pageCtr.removeListener(_onPageScroll); + _pageCtr.dispose(); + super.dispose(); + } + + void _onPageScroll() { + if (mounted) setState(() {}); + } + + void _onTimer(Timer timer) { + final page = _pageCtr.page?.toInt() ?? 0; + _pageCtr.animateToPage( + page + 1, + duration: const Duration(milliseconds: 500), + curve: Curves.ease, + ); + } + + @override + Widget build(BuildContext context) { + if (_ads.isEmpty) return const SizedBox(); + return Stack( + fit: StackFit.expand, + children: [ + PageView.builder( + controller: _pageCtr, + itemCount: 1000 + _len * 100, + allowImplicitScrolling: true, + onPageChanged: (index) => setState(() {}), + itemBuilder: (ctx, index) { + final ad = _ads[index % _len]; + return AdsItem( + adInfo: ad, + showType: AdShowType.splash, + ); + }, + ), + Positioned( + bottom: 7, + child: _buildIndicator(), + ), + ], + ); + } + + Widget _buildIndicator() { + int curPageIndex = 0; + if (_pageCtr.hasClients && _ads.isNotEmpty) { + curPageIndex = ((_pageCtr.offset + 200.0) ~/ screen.screenWidth) % _len; + } + // 复用共用指示器(自带切换动画);尺寸沿用启动页原值 + return SizedBox( + width: Get.width - 14 * 2, + child: Center( + child: CIndicator( + itemCount: _len, + selectIndex: curPageIndex, + space: 10, + dotSize: 8, + selectWidth: 25, + color: Colors.white.withValues(alpha: 0.5), + selectColor: AppColors.actionRed.withValues(alpha: 0.5), + ), + ), + ); + } +} diff --git a/lib/hj_page/splash/splash_logic.dart b/lib/hj_page/splash/splash_logic.dart new file mode 100644 index 0000000..18306d4 --- /dev/null +++ b/lib/hj_page/splash/splash_logic.dart @@ -0,0 +1,296 @@ +import 'dart:convert'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/config/address.dart'; +import 'package:hgdj/config/config.dart'; +import 'package:hgdj/hj_model/home/plate_model.dart'; +import 'package:hgdj/hj_model/splash/ads_model.dart'; +import 'package:hgdj/hj_model/splash/cut_info.dart'; +import 'package:hgdj/hj_model/splash/domain_source_model.dart'; +import 'package:hgdj/hj_model/user/user_info_model.dart'; +import 'package:hgdj/hj_page/main_page/main_logic.dart'; +import 'package:hgdj/hj_page/main_page/main_page.dart'; +import 'package:hgdj/hj_page/pre_sale/limit_time_provider.dart'; +import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/detect_line_manager.dart'; +import 'package:hgdj/hj_utils/local_server.dart'; +import 'package:hgdj/hj_utils/local_server_guard.dart'; +import 'package:hgdj/hj_utils/text_util.dart'; +import 'package:hgdj/hj_utils/version_util.dart'; +import 'package:hgdj/tools_base/ad_manager.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/net/api_exception.dart'; +import 'package:hgdj/tools_base/net/net_manager.dart'; +import 'package:hgdj/tools_base/widget/common_alert.dart'; +import 'package:path/path.dart' as path; + +import '../../alert/splash/line_error_dialog.dart'; +import '../../alert/splash/update_dialog.dart'; +import '../../hj_utils/free_play_manager.dart'; +import '../../tools_base/loading/loading_alert_widget.dart'; +import '../../track_event_manager/device_service.dart'; +import '../live/live_main_page.dart'; +import '../main_page/provider/msg_provider.dart'; + +//启动页 +class SplashLogic extends GetxController { + // ===== 开屏广告 ===== + List? adsList; //开屏广告,没有就直接过 + bool isAdLoaded = false; //广告数据已就位(可能为空),页面据此决定要不要挂广告层 + bool isAdDone = false; //广告已放完/跳过,只等数据就绪 + + // ===== 启动数据 ===== + bool isDataReady = false; //模块列表已落地 + bool _isExiting = false; //强更被拒、已请求退出 app:后续流程一律不再放行首页 + + @override + onReady() { + super.onReady(); + _start(); + } + + Future _start() async { + netManager.clearUserAgent(); //清除老的ua + await AdManager().preload(); //预热广告内存缓存,后续统一用 AdManager().adsByType + _loadAds(isLast: true); //获取上次启动的广告 + final line = await _detectLine(); //选线 + //拿不到线路就到此为止:要么用户放弃了重试,要么弹框回调里重试成功、 + //已经自己走了一遍 _initByLine。这里再调一次会重复初始化 + if (TextUtil.isEmpty(line)) return; + _initByLine(line!); //初始化流程 + } + + //选到线路后的完整启动流程 + Future _initByLine(String line) async { + //1. 初始化网络层 + _initNet(line); + //2.先登录,后升级,不然渠道一升级就没有量了,先登录才能获取配置信息 + await _devLogin(); + // 模块列表只依赖登录拿到的 token,请求先发出去跟下面的配置/更新检查并行跑, + // 但结果留到更新检查之后才放行(见末尾)——强更期间不能让首页变成可跳 + // ignore():先占个监听位,免得错误在 await 到之前被当成未捕获异常上报;真正的错误照旧在 await 处抛 + final tagTask = CommonService.getTagMarks()..ignore(); + //2. 获取远程配置 + final versions = await _fetchConfig(); + PreSaleProvider().refreshConfig(); + MineMsgProvider().startPayTimer(); //冷启动 /ping/domain 下发支付分层配置后启动倒计时 + FreePlayManager().refresh(); //免费次数进首页才用得到,不 await、不挡启动 + await _checkUpdate(versions); //强更时会停在启动页,必须挡在放行之前 + if (_isExiting) return; //强更被拒,app 正在退出,别再往下放行 + + _initServer(); // 初始化localserver缓存 + await _waitModules(tagTask); //放行跳首页 + } + + ///isLast = true 取出上次展示的广告 + Future _loadAds({bool isLast = true}) async { + final list = + isLast ? await AdManager().lastAdsByType(1) : AdManager().adsByType(1); + if (AdManager().showAbTestAd) adsList = list; + isAdLoaded = true; + update(); + } + + ///一直检查网络直到成功 + Future _detectLine() async { + LoadingAlertWidget.show(title: "选线中..."); + + final net = await Connectivity().checkConnectivity(); + // 有网络,开始选线 + if (net != ConnectivityResult.none) { + final line = await DetectLineManager().detectLineOnce(); + LoadingAlertWidget.cancel(); + if (TextUtil.isNotEmpty(line)) return line; + } + await LineErrorDialog.show(callback: () async { + final line = await DetectLineManager().detectLineOnce(); + LoadingAlertWidget.cancel(); + if (TextUtil.isNotEmpty(line)) _initByLine(line); //初始化流程 + }); + LoadingAlertWidget.cancel(); + return null; + } + + void _initNet(String host) { + Address.baseHost = host; + Address.baseApiPath = path.join(Address.baseHost ?? "", Address.apiPrefix); + netManager.init(Address.baseApiPath ?? ""); + } + + ///登录 + Future _devLogin() async { + String paste = ""; + //2.获取渠道码 + final channel = await DeviceInfoService.getChannel(); + if (TextUtil.isNotEmpty(channel)) { + final traceId = await DeviceInfoService.fetchTraceId(); + final cutInfo = CutInfo() + ..dc = channel + ..tid = traceId; + paste = json.encode(cutInfo.toJson()); + } else { + //1.获取粘贴板(走缓存,避免 iOS 多次弹系统读取提示) + final clipText = await DeviceInfoService.getClipboardTextCached(); + if (TextUtil.isNotEmpty(clipText)) { + paste = clipText!; + } + } + final deviceId = DeviceInfoService.deviceId; + await netManager.refreshUserAgent(); + //登录 + 重试 + while (true) { + final userInfo = await globalStore.loginByDevice(deviceId, paste: paste); + + if (userInfo != null) return userInfo; //免广告策略已在 globalStore 登录成功时算好 + + final retry = await CommonAlert.show( + content: "登录失败,是否重试?", + confirmText: '重试', + ); + + if (retry != true) return null; + } + } + + //拉远端配置并落地,失败弹框重试;用户放弃就返回空版本列表(等于不检查更新) + Future> _fetchConfig() async { + List? versions; + while (versions == null) { + try { + final info = await CommonService.fetchRemoteConfig(); + if (info == null) { + // 配置拉取失败(无 token / 网络异常 / 返回体异常),走下面 catch 的重试逻辑 + throw Exception('获取远端配置为空'); + } + await _applyDomains(info); + await AdManager().saveAds(info.ads?.adsInfoList); + + await _loadAds(isLast: false); + Config.aiUndressPrice = info.aiUndressPrice ?? ''; + Config.aiVideoPrice = info.aiImageToVideoPrice ?? ''; + Config.aiDrawPrice = info.aiTextToImagePrice ?? ''; + Config.aiNovelPrice = info.aiTextToNovelPrice ?? ''; + Config.isStoreOpen = info.storeIsOpen ?? false; + Config.jgArea = info.jgArea; + versions = info.ver ?? []; + //直播模块开关 + LiveMainPage.broadcast = info.broadcast ?? false; + //预售相关 + presaleProvider.advanceStatus = info.advanceStatus; + presaleProvider.advancePage = info.advancePage; + //幸运抽奖相关 + presaleProvider.luckyDrawH5 = info.luckyDrawH5; + presaleProvider.luckyDrawIcon = info.luckyDrawIcon; + + //限时活动相关 + limitTimeProvider.bannerJump = info.bannerJump; + Config.proxyBanner = info.proxyBannerJump; + Config.hotWords = info.hotSearchTerms ?? []; + Config.searchHints = info.searchHintWord ?? []; + + Config.darkWebVipId = info.darkWebVipId; + Config.darkWebVipName = info.darkWebVipName; + Config.shortDramaCardId = info.shortDramaCardId; + } catch (e) { + debugLog('getRemoteConfig', e.toString()); + final retry = await CommonAlert.show( + content: "获取配置信息失败${(e is ApiException) ? e.code : 1500},是否重试?", + confirmText: '重试', + ); + if (retry != true) break; + } + } + return versions ?? []; + } + + //域名/公告落地 + Future _applyDomains(DomainSourceModel info) async { + //域名列表信息 + for (final source in info.sourceList!) { + switch (source.type) { + case "IMAGE": + Address.baseImagePath = source.domain![0].url; + break; + case "VID": + Address.cdnAddressLists = source.domain!; + Address.cdnAddress = Address.cdnAddressLists[0].url; + break; + case "GUIDE": + Address.groundUrl = source.domain![0].url; + break; + case 'AUDIO': + Address.audioCdnAddress = source.domain?.firstOrNull?.url; + break; + } + } + + AdManager().announceList = info.ads?.announList ?? []; + } + + ///检查是否需要更新 + Future _checkUpdate(List versions) async { + //存内存,设置页的「检查更新」直接读这份比对,不再发请求 + saveVersion(versions); + final target = checkUpdate(); + if (target == null) return; + // 用户是否点了更新 + final isUpdate = await UpdateDialog.show(target); + if (isUpdate != true && target.forcedUpdate == true) { + //SystemNavigator.pop 只 finish Activity,engine 还能活几百毫秒, + //这期间后面的流程会照跑、够把首页放行出去,所以打标记让调用方直接收尾 + _isExiting = true; + await SystemChannels.platform.invokeMethod('SystemNavigator.pop'); + } + } + + //本地缓存服务:守护进程被内部 Timer 持有,不用再存字段 + Future _initServer() async { + final server = CacheServer(cacheManager: null, openSubManager: true); + await LocalServerGuard(server).run(); + //其余任意文件拦截 + server.addReqFilter(LOCAL_ALL_FILTER, Address.baseImagePath!); + } + + //模块数据落地 → 放行跳首页。请求在登录后就发出去了,这里只等结果。 + //拿不到模块一律不放行:首页 tab 全靠它,宁可停在启动页重试,也不进只剩"最新"的降级首页。 + //弹框只给"确定"一个选择,且不判返回值——点遮罩关掉也照样重试,不给静默卡死的出口 + Future _waitModules(Future task) async { + var pending = task; + while (true) { + HomePlateModel? model; + try { + model = await pending; + } catch (e) { + //网络异常和 fromJson 异常都被网络层吞成 null,走不到这;兜的是请求前置(取token/签名)抛的异常 + debugLog('getTagMarks', e.toString()); + } + if (model != null) { + Config.plateModule = model; + isDataReady = true; + _tryJump(); + return; + } + await CommonAlert.show(content: "获取模块信息失败,请重试", showCancel: false); + pending = CommonService.getTagMarks(); //旧 Future 已完成,重试必须重新发请求 + } + } + + //广告结束:标记可跳转、刷新出过渡遮罩,再尝试进首页 + void onAdFinish() { + isAdDone = true; + update(); + _tryJump(); + } + + //广告和数据两边都就绪才进首页 + void _tryJump() { + //opaque 在 offAll 里默认 false,不显式打开会在转场时透出下层 + if (isAdDone && isDataReady) + Get.offAll(() => const MainPage(), + binding: MainPageBinding(), opaque: true); + } +} diff --git a/lib/hj_page/splash/splash_page.dart b/lib/hj_page/splash/splash_page.dart new file mode 100644 index 0000000..29ec2df --- /dev/null +++ b/lib/hj_page/splash/splash_page.dart @@ -0,0 +1,55 @@ +// ignore_for_file: use_build_context_synchronously, depend_on_referenced_packages + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/splash/splash_ad_view.dart'; +import 'package:hgdj/hj_page/splash/splash_logic.dart'; + +import '../../config/config.dart'; +import '../../tools_base/loading/loading_alert_widget.dart'; + +class SplashPage extends StatelessWidget { + static const routeName = '/SplashPage'; + const SplashPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + //这里用 GetBuilder 主要是预加载背景图 + body: GetBuilder( + init: SplashLogic(), + builder: (logic) => Stack( + fit: StackFit.expand, + children: [ + Image.asset( + 'ic_splash_bg.webp'.launchPath, + fit: BoxFit.fill, + width: Get.width, + height: Get.height, + ), + if (logic.isAdLoaded || logic.adsList != null) + SplashAdView( + adData: logic.adsList, + callback: logic.onAdFinish, + ), + const Positioned( + bottom: 20, + right: 20, + child: Text( + "V${Config.innerVersion}", + style: TextStyle( + color: AppColors.actionRed, + fontSize: 8, + ), + ), + ), + //广告先结束、首页数据还没就绪时的过渡遮罩 + if (logic.isAdDone) LoadingAlertWidget(title: '数据初始化中...'), + ], + ), + ), + ); + } +} diff --git a/lib/hj_page/user_center_page/user_center_logic.dart b/lib/hj_page/user_center_page/user_center_logic.dart new file mode 100644 index 0000000..ceb43e0 --- /dev/null +++ b/lib/hj_page/user_center_page/user_center_logic.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/user/user_info_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +class UserCenterController extends GetxController + with GetTickerProviderStateMixin { + // ===== 数据 ===== + final int id; + UserInfoModel? model; + + UserCenterController(this.id); + + final titleArr = ['影视', '抖音', '帖子', '图集']; + final titleParam = [ + MediaStyle.Video, + MediaStyle.ShortVideo, + MediaStyle.Community, + MediaStyle.Pic + ]; + + // ===== 头部测量 ===== + //头部高度需实测:量到之前只渲染 header 本身,量到后才切 SliverAppBar + double? expandedHeight; + final headerKey = GlobalKey(); + + // ===== 交互态 ===== + //滚过头图后在 AppBar 上显示用户信息条 + final isShowTitle = false.obs; + + // ===== Controller ===== + final scrollCtr = ScrollController(); + late final TabController mainTabCtr; + + // ===== 生命周期 ===== + @override + void onInit() { + super.onInit(); + mainTabCtr = TabController(length: titleArr.length, vsync: this); + scrollCtr.addListener(_onScroll); + } + + @override + void onReady() { + super.onReady(); + fetchUserInfo(); + } + + @override + void onClose() { + scrollCtr.dispose(); + mainTabCtr.dispose(); + super.onClose(); + } + + // ===== 公开方法 ===== + /// 量出头部实际高度,量到后 build 才切到 NestedScrollView + void calculateHeaderHeight() { + if (expandedHeight != null) return; + final renderBox = + headerKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) { + debugLog('无法获取 calculateHeaderHeight'); + } else { + expandedHeight = renderBox.size.height; + } + update(); //量到则切正式布局;没量到则再触发一次 build 重来 + } + + Future fetchUserInfo() async { + //是自己则先拿本地信息垫一下,避免等接口白屏 + if (globalStore.isMe(id)) { + model = globalStore.meInfo; + update(); + } + model = await MineService.getUserInfo(id); + update(); + } + + /// 关注/取关成功后更新本地状态;[followStateBeforeAction] 为操作前的关注状态(与 [FollowButton] 回调一致) + void onUserFollowSuccess(bool followStateBeforeAction) { + if (model == null) return; + final wasFollow = followStateBeforeAction; + final nowFollow = !wasFollow; + + model!.isFollow = nowFollow; + final fans = model!.fans ?? 0; + if (nowFollow) { + model!.fans = fans + 1; + } else if (fans > 0) { + model!.fans = fans - 1; + } + update(); + } + + // ===== 私有方法 ===== + void _onScroll() => isShowTitle.value = scrollCtr.offset > 190; +} diff --git a/lib/hj_page/user_center_page/user_center_page.dart b/lib/hj_page/user_center_page/user_center_page.dart new file mode 100644 index 0000000..af6497f --- /dev/null +++ b/lib/hj_page/user_center_page/user_center_page.dart @@ -0,0 +1,166 @@ +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/user_center_page/view/user_work_list_view.dart'; +import 'package:hgdj/hj_page/user_center_page/widget/user_center_header.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/follow_button.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; + +import '../../tools_base/indicator/custom_tab_indicator.dart'; +import '../../tools_base/widget/net_image_widget.dart'; +import 'user_center_logic.dart'; + +//用户首页 +class UserCenterPage extends StatefulWidget { + final int uid; + + const UserCenterPage({super.key, required this.uid}); + + @override + State createState() => _UserCenterPageState(); +} + +//可从评论/短视频等多处重复 push,用 uniqueTag 隔离每个实例的 logic +class _UserCenterPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: UserCenterController(widget.uid), + tag: uniqueTag, + builder: (logic) => Scaffold( + extendBodyBehindAppBar: true, + body: _buildContent(logic), + ), + ); + } + + Widget _buildContent(UserCenterController logic) { + if (logic.model == null) return const LoadingCenterWidget(); + // 头部高度未知:先只渲染 header 供测量,量到后 update 会切到下面的正式布局 + if (logic.expandedHeight == null) { + WidgetsBinding.instance + .addPostFrameCallback((_) => logic.calculateHeaderHeight()); + return Container( + alignment: Alignment.topCenter, + child: UserCenterHeader(key: logic.headerKey, logic: logic), + ); + } + return ExtendedNestedScrollView( + controller: logic.scrollCtr, + onlyOneScrollInBody: true, + physics: const BouncingScrollPhysics(), + headerSliverBuilder: (_, __) => [_buildAppBar(logic)], + body: TabBarView( + controller: logic.mainTabCtr, + children: List.generate( + logic.titleArr.length, + (index) => + UserWorkListView(logic.model?.uid ?? 0, logic.titleParam[index]) + .keepAlive, + ), + ), + ); + } + + Widget _buildAppBar(UserCenterController logic) { + return SliverAppBar( + expandedHeight: logic.expandedHeight, + centerTitle: false, + titleSpacing: 0, + title: _buildAppBarTitle(logic), + leading: Center( + child: InkWell( + enableFeedback: false, + onTap: () => Get.back(), + child: const Icon(Icons.arrow_back_ios), + ), + ), + backgroundColor: Colors.black, + pinned: true, + floating: false, + elevation: 0, + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + background: UserCenterHeader(logic: logic), + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(44), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + width: double.infinity, + height: 44, + color: Colors.black, + child: TabBar( + controller: logic.mainTabCtr, + tabAlignment: TabAlignment.fill, + tabs: logic.titleArr + .map((e) => + Container(alignment: Alignment.center, child: Text(e))) + .toList(), + labelStyle: const TextStyle(fontSize: 14), + labelColor: const Color(0xE5FFFFFF), + unselectedLabelStyle: const TextStyle(fontSize: 14), + unselectedLabelColor: const Color(0x73FFFFFF), + padding: EdgeInsets.zero, + isScrollable: false, + labelPadding: EdgeInsets.zero, + indicator: CustomIndicator( + borderRadius: BorderRadius.circular(1.5), + height: 3, + width: 13, + isGradient: true, + offsetY: 4, + ), + ), + ), + ), + ); + } + + //滚过头图后,AppBar 上浮出头像 + 昵称 + 关注按钮 + Widget _buildAppBarTitle(UserCenterController logic) { + return Obx(() { + if (!logic.isShowTitle.value) return const SizedBox(); + return Container( + color: Colors.black, + child: Row( + children: [ + NetworkImageLoader( + imageUrl: logic.model?.portrait ?? '', + width: 38, + height: 38, + borderRadius: 38, + ), + 12.sizeBoxW, + //保留 Column:它把昵称顶到 Row 顶部,直接放 Text 会变成垂直居中 + Column( + children: [ + Text( + logic.model?.name ?? "", + style: const TextStyle( + fontSize: 14, + color: Color(0xffffffff), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const Spacer(), + if (!globalStore.isMe(logic.model?.uid)) + FollowButton( + mediaId: (logic.model?.uid ?? 0).toString(), + isFollow: logic.model?.isFollow, + followType: FollowEnum.user, + successsAction: logic.onUserFollowSuccess, + ), + 16.sizeBoxW, + ], + ), + ); + }); + } +} diff --git a/lib/hj_page/user_center_page/view/user_work_list_logic.dart b/lib/hj_page/user_center_page/view/user_work_list_logic.dart new file mode 100644 index 0000000..2b339e3 --- /dev/null +++ b/lib/hj_page/user_center_page/view/user_work_list_logic.dart @@ -0,0 +1,32 @@ +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/base_list_controller.dart'; + +class UserWorkListLogic extends ListBaseLogic { + final int uid; + final MediaStyle newsType; + + UserWorkListLogic(this.uid, this.newsType); + + @override + void onInit() { + super.onInit(); + loadData(); + } + + //isRefresh 下拉刷新/加载更多;showLoading 重试时先清空显示 loading + void loadData({bool isRefresh = true, bool showLoading = false}) { + if (showLoading) { + dataList = null; + update(); + } + fetchData(isRefresh: isRefresh, fetch: _fetch); + } + + Future<(List?, bool)> _fetch(int page) async { + final res = await MineService.fetchPublishes( + page: page, uid: uid, newsType: newsType.searchRealm); + return (res.list, res.hasNext ?? false); + } +} diff --git a/lib/hj_page/user_center_page/view/user_work_list_view.dart b/lib/hj_page/user_center_page/view/user_work_list_view.dart new file mode 100644 index 0000000..b1081b0 --- /dev/null +++ b/lib/hj_page/user_center_page/view/user_work_list_view.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/user_center_page/view/user_work_list_logic.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +import '../../../routers/jump_router.dart'; +import '../../cartoon/photo_gallery_item.dart'; +import '../../community/widget/community_post_widget.dart'; +import '../../home/home_cell_style/video_simple_cell.dart'; + +class UserWorkListView extends StatefulWidget { + final int uid; + final MediaStyle newsType; + + const UserWorkListView(this.uid, this.newsType, {super.key}); + + @override + State createState() => _UserWorkListViewState(); +} + +class _UserWorkListViewState extends State + with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: UserWorkListLogic(widget.uid, widget.newsType), + tag: uniqueTag, + builder: (logic) { + if (logic.isLoading) return const LoadingCenterWidget(); + if (logic.isEmptyData) + return CErrorWidget( + retryOnTap: () => logic.loadData(showLoading: true)); + return pullYsRefresh( + onInit: (ctr) => logic.refreshCtr = ctr, + onRefresh: (_) => logic.loadData(), + onLoading: (_) => logic.loadData(isRefresh: false), + child: _buildTypeList(logic), + ); + }, + ); + } + + Widget _buildTypeList(UserWorkListLogic logic) { + final list = logic.dataList!; + // 帖子:单列列表 + if (widget.newsType == MediaStyle.Community) { + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: list.length, + itemBuilder: (_, index) => + CommunityPostWidget(videoModel: list[index], videoModels: list), + ); + } + // 其余(影视/抖音/图集)都是双列网格,只差宽高比与 item 样式 + final isPic = widget.newsType == MediaStyle.Pic; + final isShort = widget.newsType == MediaStyle.ShortVideo; + return GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 6, + childAspectRatio: isShort || isPic ? 168 / 246 : 168 / 154, + ), + itemCount: list.length, + itemBuilder: (_, index) => isPic + ? PhotoGalleryItem(videoModel: list[index], textline: 1) + : VideoSimpleCell( + videoModel: list[index], + textLines: isShort ? 1 : 2, + isShowBottom: !isShort, + onTap: () => + pushToVideoPage(videoModel: list[index], videoArr: list), + ), + ); + } +} diff --git a/lib/hj_page/user_center_page/widget/user_center_header.dart b/lib/hj_page/user_center_page/widget/user_center_header.dart new file mode 100644 index 0000000..fbe1b1e --- /dev/null +++ b/lib/hj_page/user_center_page/widget/user_center_header.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_page/user_center_page/user_center_logic.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; +import 'package:hgdj/tools_base/widget/follow_button.dart'; + +import '../../../tools_base/widget/net_image_widget.dart'; + +/// 用户中心头部:背景图 + 头像 + 昵称 / 作品粉丝数 / 简介 +class UserCenterHeader extends StatelessWidget { + final UserCenterController logic; + + const UserCenterHeader({super.key, required this.logic}); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black, + child: Stack( + children: [ + //背景图,foregroundDecoration 是压在图上的暗色遮罩 + Positioned( + left: 0, + right: 0, + child: Container( + height: 190, + foregroundDecoration: BoxDecoration( + color: const Color(0xff151515).withValues(alpha: 0.1)), + child: Image.asset("user_center_bg.webp".mineImgPath, + fit: BoxFit.fill), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + //背景图 190 高,头像下沿压过来 24,与背景交叠 + (190 - 24).sizeBoxH, + //头像 + 关注按钮 + SizedBox( + height: 80, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + height: 80, + width: 80, + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border.all(color: const Color(0xff030F18)), + borderRadius: BorderRadius.circular(40), + ), + child: NetworkImageLoader( + imageUrl: logic.model?.portrait ?? "", + height: 74, + width: 74, + borderRadius: 40, + ), + ), + const Spacer(), + if (!globalStore.isMe(logic.model?.uid)) + ..._actionButtons(), + ], + ), + ), + 12.sizeBoxH, + //昵称 + Text( + logic.model?.name ?? "", + style: const TextStyle( + color: Color(0xffEFEFEF), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + 6.sizeBoxH, + //作品数 / 粉丝数 + Text( + "${logic.model?.totalWorkCount ?? "0"} 作品 ${logic.model?.fans?.countStr ?? "0"}粉丝", + style: + const TextStyle(color: Color(0xff656565), fontSize: 12), + ), + 24.sizeBoxH, + //个人简介 + if (logic.model?.summary?.isNotEmpty == true) + Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Text( + logic.model?.summary ?? "", + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0x99FFFFFF), fontSize: 12, height: 1.5), + ), + ), + ], + ), + ), + ], + ), + ); + } + + //关注,仅他人主页展示 + List _actionButtons() { + return [ + FollowButton( + mediaId: (logic.model?.uid ?? 0).toString(), + followType: FollowEnum.user, + isFollow: logic.model?.isFollow, + successsAction: logic.onUserFollowSuccess, + ), + ]; + } +} diff --git a/lib/hj_page/video/cartoon/cartoon_episode_menu_alert.dart b/lib/hj_page/video/cartoon/cartoon_episode_menu_alert.dart new file mode 100644 index 0000000..11f1e6b --- /dev/null +++ b/lib/hj_page/video/cartoon/cartoon_episode_menu_alert.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; +import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart'; +import 'package:pull_to_refresh/pull_to_refresh.dart'; + +import '../../../hj_model/acg/comic_chapters_model.dart'; +import '../../../hj_model/cartoon_media_info.dart'; +import '../../../hj_utils/api_service/acg_service.dart'; +import '../../cartoon/widget/free_badge.dart'; +import '../../live/live_widget.dart'; + +class CartoonEpisodeMenuAlert extends StatefulWidget { + final ComicChapterInfo? curModel; + final CartoonMediaInfo? cartoonModel; + final Function(ComicChapterInfo)? callback; + + CartoonEpisodeMenuAlert(this.curModel, + {super.key, this.callback, this.cartoonModel}); + + @override + State createState() { + return _CartoonEpisodeMenuAlertState(); + } +} + +class _CartoonEpisodeMenuAlertState extends State { + CartoonMediaInfo? get cartoonModel => widget.cartoonModel; + RefreshController? refreshCtr; + + bool _isLoading = false; + + void _loadMoreData() async { + if (_isLoading) return; + _isLoading = true; + try { + final currentPage = cartoonModel?.episodeCurPage ?? 0; + final episodeModel = await ACGService.getChapterList( + cartoonModel?.id ?? "", + currentPage + 1, + cartoonModel?.episodePageSize ?? 40, + sortType: cartoonModel?.isUpSort, + ); + if ((currentPage + 1) == 1) { + cartoonModel?.episodeList = episodeModel?.list; + } else { + cartoonModel?.episodeList?.addAll(episodeModel?.list ?? []); + } + cartoonModel?.episodeCurPage = currentPage + 1; + cartoonModel?.haxNextEpisode = episodeModel?.hasNext ?? true; + } catch (e) { + debugLog(e); + } + _isLoading = false; + //异步返回时 sheet 可能已关闭,避免对已卸载组件 setState / 操作已释放的 refreshCtr + if (!mounted) return; + setState(() {}); + cartoonModel?.haxNextEpisode == true + ? refreshCtr?.loadComplete() + : refreshCtr?.loadNoData(); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Container( + padding: EdgeInsets.fromLTRB(18.w, 21, 18.w, 32), + height: 460, + decoration: BoxDecoration( + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + color: Color(0xff0F0F0F)), + child: Column( + children: [ + const SheetHandleBar(color: Color(0x4DFFFFFF)), + 18.h.sizeBoxH, + Row( + children: [ + Text( + "剧集列表", + style: TextStyle( + fontSize: 20.sp, + color: Colors.white, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + 18.h.sizeBoxH, + Expanded( + child: pullYsRefresh( + onInit: (ctr) => refreshCtr = ctr, + enablePullDown: false, + onLoading: (refreshCtr) => _loadMoreData(), + noDataText: "", + child: GridView.builder( + padding: EdgeInsets.zero, + itemCount: cartoonModel?.episodeList?.length ?? 0, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6, + crossAxisSpacing: 8, + mainAxisSpacing: 12, + childAspectRatio: 1, + ), + itemBuilder: (context, index) { + ComicChapterInfo model = cartoonModel!.episodeList![index]; + bool isSelected = model.id == widget.curModel?.id; + return GestureDetector( + onTap: () { + if (!isSelected) { + widget.callback?.call(model); + Get.back(); + } + }, + child: CartoonChapterItem( + model, + isSelected: isSelected, + fatherM: cartoonModel!, + ), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } +} + +class CartoonChapterItem extends StatelessWidget { + final ComicChapterInfo model; + final CartoonMediaInfo fatherM; + final bool isSelected; + final bool canPlay; + + const CartoonChapterItem(this.model, + {super.key, + this.isSelected = false, + this.canPlay = true, + required this.fatherM}); + + // 前 N 集免费(当前集号 <= freeEpisode)且整本无任何权限时,展示「免费」角标 + bool get _isFree => + fatherM.hasPermission == false && + fatherM.isFreeEpisodeNumber(model.episodeNumber); + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: isSelected ? Color(0x1A6975FF) : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? AppColors.actionRed : Color(0x1AFFFFFF), + width: 1), + ), + child: Stack( + alignment: Alignment.center, + children: [ + //前 N 集免费且整本无权限 → 左上角「免费」角标 + if (_isFree) + const Positioned(top: 0, left: 0, child: FreeBadge(isCorner: true)), + Container( + padding: EdgeInsets.only(top: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${model.episodeNumber}", + style: TextStyle( + color: isSelected ? AppColors.actionRed : Color(0xE5FFFFFF), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + if (isSelected && canPlay) ...[ + 2.sizeBoxW, + AudioWaveView( + color: AppColors.actionRed, + height: 10, + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/video/cartoon/video_detail_cartoon_view.dart b/lib/hj_page/video/cartoon/video_detail_cartoon_view.dart new file mode 100644 index 0000000..4310cf2 --- /dev/null +++ b/lib/hj_page/video/cartoon/video_detail_cartoon_view.dart @@ -0,0 +1,374 @@ +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/home/tag/cartoon_tag_page.dart'; +import 'package:hgdj/hj_page/video/view/video_detail_bottom_menu.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/routers/jump_router.dart'; +import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; +import 'package:hgdj/tools_base/widget/shrink_wrap.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../alert/video/share_media_dialog.dart'; +import '../../../hj_model/acg/comic_chapters_model.dart'; +import '../../../hj_model/cartoon_media_info.dart'; +import '../../cartoon/cartoon_recommend_page.dart'; +import 'cartoon_episode_menu_alert.dart'; + +/// 动漫视频详情页 tab 第 0 页:作品信息 + 选集 + 推荐 +class VideoDetailCartoonView extends StatefulWidget { + final VideoModel? model; + final ComicChapterInfo? currentEpisode; + final VideoPlayerController? playCtr; + final Function(VideoModel model)? vmCallback; + final Function(ComicChapterInfo model)? episoCallback; // 动漫选集回调 + + const VideoDetailCartoonView({ + super.key, + this.model, + this.playCtr, + this.vmCallback, + this.currentEpisode, + this.episoCallback, + }); + + @override + State createState() => _VideoDetailCartoonViewState(); +} + +class _VideoDetailCartoonViewState extends State + with SingleTickerProviderStateMixin { + VideoModel? get videoModel => widget.model; + + // 当前内容是动漫,动漫推荐按本作 acg tag(mediaInfo.tagDetails) 拉相似作品 + String? get _acgTagId => videoModel?.mediaInfo?.tagDetails?.firstOrNull?.id; + + late final TabController tabCtr = TabController(length: 3, vsync: this); + + // 动漫页菜单顺序跟真人页不一样,视频推荐排在中间 + final List menuTitles = const ["动漫推荐", "视频推荐", "漫画推荐"]; + + @override + void dispose() { + tabCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ExtendedNestedScrollView( + onlyOneScrollInBody: true, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverToBoxAdapter( + child: Container( + padding: EdgeInsets.fromLTRB(12, 12, 12, 0), + child: _buildCartoonInfo(), + ), + ), + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.only(top: 18), + margin: EdgeInsets.symmetric(horizontal: 12), + child: + VideoDetailBottomMenu(model: videoModel, onShare: _onShare), + ), + ), + SliverToBoxAdapter( + child: Container( + margin: EdgeInsets.fromLTRB(12, 18, 12, 18), + child: 0.5.line, + ), + ), + SliverToBoxAdapter( + child: VideoCartoonEpisodeMenu( + widget.currentEpisode, + cartoonModel: videoModel?.mediaInfo, + callback: widget.episoCallback, + ), + ), + SliverToBoxAdapter( + child: Container( + margin: EdgeInsets.fromLTRB(12, 18, 12, 18), + child: 0.5.line, + ), + ), + SliverToBoxAdapter( + child: AdsGridViewWidget( + 6, + padding: EdgeInsets.only(left: 12, bottom: 18), + ), + ), + SliverToBoxAdapter(child: _buildTitleMenu()), + ]; + }, + body: TabBarView( + // 换播放源后 videoModel.id 变化 → 整个推荐区重建,按新视频刷新 + key: ValueKey('rec_${videoModel?.id}'), + controller: tabCtr, + children: [ + // 动漫推荐(当前内容同类,带本作 tagId 拉相似) + CartoonRecommendPage( + mediaStyle: MediaStyle.Cartoon, + mediaId: _acgTagId, + childAspectRatio: 111 / 174, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + onAcgTap: _onAcgCellTap, + ).keepAlive, + // 视频推荐(非当前内容类型,不带 tagId) + CartoonRecommendPage( + mediaStyle: MediaStyle.Video, + crossAxisCount: 2, + childAspectRatio: 168 / 142, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + onVideoTap: _onVideoCellTap, + ).keepAlive, + // 漫画推荐 + CartoonRecommendPage( + mediaStyle: MediaStyle.Comics, + childAspectRatio: 111 / 174, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + onAcgTap: _onAcgCellTap, + ).keepAlive, + ], + ), + ); + } + + /// 分享:弹分享面板 + void _onShare() { + Get.dialog(ShareMediaDialog(videoModel: videoModel)); + } + + /// 动漫作品信息:标题 + 标签(原 VideoDetailCartoonInfoWidget 单处使用,已内联) + Widget _buildCartoonInfo() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildRichText(), + _buildCartoonTags(), + ], + ); + } + + Widget _buildRichText() { + if (videoModel?.title?.trim().isNotEmpty == true) { + return Container( + padding: EdgeInsets.only(bottom: 0), + child: Text( + videoModel?.title?.trim() ?? "", + style: TextStyle( + color: Colors.white, + height: 1.5, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ); + } + return SizedBox(); + } + + Widget _buildCartoonTags() { + if (videoModel?.tags?.isNotEmpty == true) { + return Container( + padding: EdgeInsets.only(top: 12), + child: ShrinkWrap( + spacing: 0, + runSpacing: 6, + maxLines: 1, + children: videoModel!.tags!.map((e) => _buildTagItem(e)).toList(), + ), + ); + } + return const SizedBox(); + } + + Widget _buildTagItem(TagsBean tag) { + return InkWell( + enableFeedback: false, + onTap: () { + widget.playCtr?.pause(); + Get.to(() => CartoonTagPage(title: tag.name ?? "", sId: tag.id), + preventDuplicates: true); + }, + child: Container( + padding: EdgeInsets.fromLTRB(8, 2, 8, 2), + margin: EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + "#${tag.name}", + style: TextStyle( + color: Color(0x73ffffff), + fontSize: 12, + ), + ), + ), + ); + } + + /// 推荐 tab 标题栏(居中 TabBar + 渐变下划线,与漫画详情页一致) + Widget _buildTitleMenu() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TabBar( + controller: tabCtr, + isScrollable: true, + tabAlignment: TabAlignment.center, + padding: EdgeInsets.symmetric(horizontal: 8), + labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + labelColor: Color(0xE5FFFFFF), + unselectedLabelColor: Color(0x8CFFFFFF), + unselectedLabelStyle: + TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + tabs: menuTitles + .map((e) => Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Text(e), + )) + .toList(), + indicator: CustomIndicator( + isGradient: true, + width: 13, + height: 3, + borderRadius: BorderRadius.circular(1.5)), + ), + ], + ); + } + + /// 视频推荐 cell 点击:无源地址/短片(<5min) → 暂停并 push 新页; + /// 同一视频 → toast;完整片 → 交 vmCallback 就地换源,不开新页 + void _onVideoCellTap(VideoModel acModel) { + if (acModel.sourceURL?.isNotEmpty != true) { + widget.playCtr?.pause(); + pushToVideoPage(videoModel: acModel); + return; + } + if (acModel.id == videoModel?.id) { + showToast("当前视频正在播放"); + return; + } + // 300 秒 = 5 分钟:短片当预览片,开新页播;长片直接在当前播放器替换源 + if ((acModel.playTime ?? 300) < 300) { + widget.playCtr?.pause(); + pushToVideoPage(videoModel: acModel); + } else { + widget.vmCallback?.call(acModel); + } + } + + /// 动漫/漫画 cell 点击:video 类型交 vmCallback 就地换源,其它跳漫画详情页 + void _onAcgCellTap(CartoonMediaInfo acModel) { + widget.playCtr?.pause(); + if (acModel.mediaType == 'video') { + // videoType=1 标记为动漫视频,让播放器走 cartoon 分支(区别于普通真人视频) + final vm = VideoModel(id: acModel.id) + ..cover = acModel.coverH + ..videoType = 1; + widget.vmCallback?.call(vm); + } else { + pushToCartoonPage(acModel); + } + } +} + +/// 选集组件:横向 episode 列表 + 「选集」按钮(点击弹 bottomSheet 全集) +class VideoCartoonEpisodeMenu extends StatelessWidget { + final CartoonMediaInfo? cartoonModel; + final ComicChapterInfo? currentEpisode; + final Function(ComicChapterInfo model)? callback; + + const VideoCartoonEpisodeMenu( + this.currentEpisode, { + super.key, + this.cartoonModel, + this.callback, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only(left: 12), + // StatefulBuilder:bottomSheet 关闭后强制刷新选中态, + // 避免父级 callback 异步、选中样式延迟更新 + child: StatefulBuilder(builder: (ctx, states) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () async { + await Get.bottomSheet(CartoonEpisodeMenuAlert( + currentEpisode, + cartoonModel: cartoonModel, + callback: (index) => callback?.call(index), + )); + states(() {}); + }, + child: Row( + children: [ + Text( + "选集", + style: TextStyle( + fontSize: 18, + color: Color(0xE5FFFFFF), + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Text( + "全${cartoonModel?.totalEpisode ?? 1}话", + style: TextStyle(fontSize: 12, color: Color(0xff989898)), + ), + 3.sizeBoxW, + Icon( + Icons.arrow_forward_ios_rounded, + color: Color(0xff989898), + size: 13, + ), + 16.sizeBoxW, + ], + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 58, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.zero, + itemCount: cartoonModel?.episodeList?.length ?? 0, + itemBuilder: (context, index) { + final model = cartoonModel!.episodeList![index]; + return Container( + width: 58, + height: 58, + margin: const EdgeInsets.only(right: 10), + child: GestureDetector( + onTap: () => callback?.call(model), + child: CartoonChapterItem( + model, + isSelected: currentEpisode?.id == model.id, + fatherM: cartoonModel!, + ), + ), + ); + }, + ), + ), + ], + ); + }), + ); + } +} diff --git a/lib/hj_page/video/simple_video_player_logic.dart b/lib/hj_page/video/simple_video_player_logic.dart new file mode 100644 index 0000000..f241a6b --- /dev/null +++ b/lib/hj_page/video/simple_video_player_logic.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:get/get.dart'; +import 'package:hgdj/hj_utils/video_view_type.dart'; +import 'package:hgdj/tools_base/event_bus/event_bus_util.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:video_player/video_player.dart'; + +import '../../tools_base/debug_log.dart'; + +class SimpleVideoPlayerLogic extends GetxController { + final String videoUrl; + final String? localPath; + + SimpleVideoPlayerLogic({required this.videoUrl, this.localPath}); + + VideoPlayerController? videoCtr; + StreamSubscription? pauseSub; + + @override + void onInit() { + super.onInit(); + // 耳机/蓝牙断开、来电中断时暂停,防止外放泄露 + pauseSub = eventBus.on((_) => videoCtr?.pause()); + } + + @override + void onReady() { + super.onReady(); + _initData(); + } + + void _initData({bool isRetry = false}) async { + try { + if (localPath?.isNotEmpty == true) { + videoCtr = PlayerFactory.file(localPath); + } else { + debugLog('url=$videoUrl'); + videoCtr = PlayerFactory.network(videoUrl); + } + await videoCtr?.initialize(); + if (isRetry) + confirmPlatformView(); // 仅重试成功(同视频 textureView 挂、platformView 放出)才落本地 + videoCtr?.play(); + update(); + } catch (e) { + debugLog("simple视频初始化失败:$e url = $videoUrl"); + // 首次芯片解码/渲染报错:仅内存切 platformView 重试当前视频,成功后才落本地 + if (isDecoderError(e) && switchToPlatformView()) { + videoCtr?.dispose(); + _initData(isRetry: true); + return; + } + showToast("视频加载失败"); + } + } + + @override + void onClose() { + pauseSub?.cancel(); + videoCtr?.dispose(); + super.onClose(); + } +} diff --git a/lib/hj_page/video/simple_video_player_page.dart b/lib/hj_page/video/simple_video_player_page.dart new file mode 100644 index 0000000..6cac1a8 --- /dev/null +++ b/lib/hj_page/video/simple_video_player_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/video/view/video_menu_view.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:video_player/video_player.dart'; + +import 'simple_video_player_logic.dart'; +import 'video_full_page.dart'; + +class SimpleVideoPlayerPage extends StatelessWidget { + final String videoUrl; + final String title; + final String? localPath; + + const SimpleVideoPlayerPage( + {super.key, required this.videoUrl, required this.title, this.localPath}); + + @override + Widget build(BuildContext context) { + return GetBuilder( + init: SimpleVideoPlayerLogic(videoUrl: videoUrl, localPath: localPath), + builder: (logic) => Scaffold( + backgroundColor: Colors.black, + appBar: AppBar(title: Text(title)), + body: Center(child: _player(logic.videoCtr)), + ), + ); + } + + Widget _player(VideoPlayerController? ctr) { + if (ctr?.value.isInitialized != true) return LoadingCenterWidget(); + return AspectRatio( + aspectRatio: ctr!.value.aspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + VideoPlayer(ctr), + VideoMenuView( + playCtr: ctr, + onFullScreen: () => Get.to(() => VideoFullPage(playCtr: ctr)), + ), + ], + ), + ); + } +} diff --git a/lib/hj_page/video/video_full_logic.dart b/lib/hj_page/video/video_full_logic.dart new file mode 100644 index 0000000..47dce82 --- /dev/null +++ b/lib/hj_page/video/video_full_logic.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:video_player/video_player.dart'; + +/// 全屏播放页业务逻辑:横竖屏切换 + 后台自动暂停 +/// 注:[playCtr] 由外部页面持有并释放,本类只借用,绝不能 dispose +class VideoFullLogic extends GetxController with WidgetsBindingObserver { + VideoFullLogic({required this.playCtr, this.isAutoV = true}); + + final VideoPlayerController playCtr; + final bool isAutoV; // true:按视频比例决定是否转横屏;false:强制转横屏 + + double get aspectRatio => playCtr.value.aspectRatio; + + @override + void onInit() { + super.onInit(); + WidgetsBinding.instance.addObserver(this); + // 横向视频(或调用方强制)才转横屏,竖屏视频保持竖屏全屏 + if (aspectRatio > 1 || !isAutoV) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: [SystemUiOverlay.bottom]); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + // 转屏后 Get.width/height 才是新值,下一帧刷一次让画面按新尺寸铺满 + WidgetsBinding.instance.addPostFrameCallback((_) => update()); + } + + /// 切后台暂停,防止息屏/切走后声音还在外放 + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.paused) playCtr.pause(); + } + + /// 退出全屏:恢复竖屏 + 状态栏,再退页 + void exitFullScreen() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: [SystemUiOverlay.bottom, SystemUiOverlay.top]); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + Get.back(); + } + + @override + void onClose() { + WidgetsBinding.instance.removeObserver(this); + super.onClose(); // playCtr 属于调用方,这里不释放 + } +} diff --git a/lib/hj_page/video/video_full_page.dart b/lib/hj_page/video/video_full_page.dart new file mode 100644 index 0000000..c27aae5 --- /dev/null +++ b/lib/hj_page/video/video_full_page.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/video/video_full_logic.dart'; +import 'package:hgdj/hj_page/video/view/video_menu_view.dart'; +import 'package:video_player/video_player.dart'; + +import '../../hj_model/video_model.dart'; +import '../../hj_utils/screen.dart'; +import '../../tools_base/unique_tag_mixin.dart'; + +/// 全屏播放页(复用外部传入的播放器控制器,不新建也不释放) +class VideoFullPage extends StatefulWidget { + final VideoPlayerController playCtr; + final VideoModel? videoModel; + final bool isAutoV; // 判断size,自动转竖屏 + final bool showMenu; //是否需要显示菜单栏,直播不需要展示菜单栏 + + const VideoFullPage({ + super.key, + required this.playCtr, + this.videoModel, + this.isAutoV = true, + this.showMenu = true, + }); + + @override + State createState() => _VideoFullPageState(); +} + +// tag 隔离:logic 持有外部传入的 playCtr。不加 tag 时上一个全屏页的 Get.delete 若没跑完, +// GetBuilder 的 init 会被跳过 → 复用旧 logic、播到上一个视频 +class _VideoFullPageState extends State with UniqueTagMixin { + VideoPlayerController get playCtr => widget.playCtr; + + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, + init: VideoFullLogic(playCtr: playCtr, isAutoV: widget.isAutoV), + builder: (logic) => PopScope( + // 系统返回键/侧滑不直接退页,先恢复竖屏再由 exitFullScreen 退 + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + logic.exitFullScreen(); + }, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + alignment: Alignment.center, + children: [ + // 撑满 Stack 用(Stack 尺寸取最大的非 Positioned 子节点)。 + // 不能改成 StackFit.expand:那会给 AspectRatio 传 tight 约束,视频比例失效 + SizedBox(width: screen.screenWidth, height: screen.screenHeight), + Hero( + tag: "player", + child: AspectRatio( + aspectRatio: logic.aspectRatio, + child: VideoPlayer(playCtr), + ), + ), + if (widget.showMenu) _menuLayer(logic), + _backBtn(logic), + ], + ), + ), + ), + ); + } + + /// 播放器控制层(进度条/倍速/快进快退) + Widget _menuLayer(VideoFullLogic logic) { + return Positioned( + bottom: 5, + left: 0, + right: 0, + child: SizedBox( + width: screen.screenWidth, + height: screen.screenHeight, + child: VideoMenuView( + playCtr: playCtr, + isFull: true, + onFullScreen: logic.exitFullScreen, + videoModel: widget.videoModel, + ), + ), + ); + } + + /// 全屏(横屏)下返回按钮避开刘海/状态栏安全区(原来用竖屏的 paddingTop 不准) + Widget _backBtn(VideoFullLogic logic) { + return SafeArea( + child: Align( + alignment: Alignment.topLeft, + child: InkWell( + enableFeedback: false, + onTap: logic.exitFullScreen, + child: const Padding( + padding: EdgeInsets.fromLTRB(5, 5, 15, 15), + child: SizedBox( + width: 30, + height: 30, + child: Icon(Icons.arrow_back_ios_sharp, color: Colors.white), + ), + ), + ), + ), + ); + } +} diff --git a/lib/hj_page/video/video_logic.dart b/lib/hj_page/video/video_logic.dart new file mode 100644 index 0000000..c4f50f3 --- /dev/null +++ b/lib/hj_page/video/video_logic.dart @@ -0,0 +1,725 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/video/video_full_page.dart'; +import 'package:hgdj/hj_page/video/view/long_video_status.dart'; +import 'package:hgdj/hj_utils/codec_support.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/play_diagnose.dart'; +import 'package:hgdj/hj_utils/video_view_type.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:video_player/video_player.dart'; + +import '../../alert/video/buy_vip_alert.dart'; +import '../../alert/video/comic_buy_alert.dart'; +import '../../alert/vip_guide/guide_bottom_sheet.dart'; +import '../../alert/vip_guide/guide_common.dart'; +import '../../alert/vip_guide/guide_config.dart'; +import '../../alert/vip_guide/guide_countdown_dialog.dart'; +import '../../alert/vip_guide/guide_free_trial_sheet.dart'; +import '../../alert/vip_guide/guide_manager.dart'; +import '../../alert/vip_guide/timed_popup_manager.dart'; +import '../../hj_model/acg/comic_chapters_model.dart'; +import '../../hj_model/cartoon_media_info.dart'; +import '../../hj_model/media_content.dart'; +import '../../hj_model/splash/ads_model.dart'; +import '../../hj_model/splash/domain_source_model.dart'; +import '../../hj_model/video_model.dart'; +import '../../hj_utils/api_service/acg_service.dart'; +import '../../hj_utils/api_service/vid_service.dart'; +import '../../hj_utils/free_play_manager.dart'; +import '../../hj_utils/history_util.dart'; +import '../../routers/jump_router.dart'; +import '../../tools_base/ad_manager.dart'; +import '../../tools_base/debug_log.dart'; +import '../../tools_base/event_bus/event_bus_util.dart'; +import '../../tools_base/event_bus/events.dart'; +import '../../tools_base/global_store/store.dart'; +import '../main_page/provider/msg_provider.dart'; +import '../pre_sale/pre_sale_provider.dart'; + +/// 视频/动漫播放页业务逻辑:拉详情 → 初始化播放器;动漫多一步章节列表 +class VideoLogic extends GetxController with GetSingleTickerProviderStateMixin { + VideoLogic({this.vid, this.isCartoon = false}); + + /// 当前播的是不是动漫。**可变**:页内切内容后按被点内容的 videoType 重定(见 [switchVideo]), + /// 走错接口会「视频资源加载失败」。页内所有判断都读它,不看进页面时的入参 + bool isCartoon; + final String? vid; + VideoModel? videoModel; + + /// 当前选定的播放 URL(按 playContentStyle / 预览权 / 265能力等条件选择,见 [_initPlayer]) + String? videoUrl; + + String? get videoCover => videoModel?.cover; + VideoPlayerController? playerCtr; + + /// 当前是否在播 H.265 源。**必须选流当下记录**,不能用 `videoUrl == realH265Url` 派生—— + /// realH265Url 拿全局 token/cdn 现拼,刷新后对不上会误判 false,265 降级链就失效了 + bool isPlayingH265 = false; + + /// 本条视频是否已回退 264(仅内存)。初始化失败或播放中报错时置位, + /// 之后所有重建都走 264;换视频时 loadData 复位 + bool _forceH264 = false; + + /// 贴片广告列表(position=50) + List adsList = []; + + /// 预览结尾 VIP 弹窗的去重标记,防止重复弹 + bool isShowDialog = false; + bool isShowAd = false; // true: 显示视频广告 + bool isFullScreen = false; // true:视频进入全屏页面 + RxBool isShowBuy = false.obs; // 显示购买和vip弹窗 + + late final TabController tabCtr = + TabController(length: 2, vsync: this); // 简介/评论 + CartoonMediaInfo? cartoonModel; //动漫 + ComicChapterInfo? currentEpisode; //动漫集数 + + /// onClose 后的标记,用于防止异步回调里再操作已释放控制器 + bool isDispose = false; + LongVideoStatus get videoStatus => longVideoStatus(videoModel); + + /// 「金币视频免看」权益:VIP+coins==0 或 剩余免次数>=0 才免看;非金币视频/动漫不享受 + bool get isVipCoinFree { + if (videoModel?.isCoinVideo() != true) { + // 非金币视频 + return false; + } + if (globalStore.isVIP && videoModel?.coins == 0) { + // vip金币视频 免看 + return true; + } else { + if (isCartoon == true) return false; // 动漫视频不享受金币免次数权益 + if (presaleProvider.coinVideoFreeCount >= 0) return true; + return false; + } + } + + bool get isInited => playerCtr?.value.isInitialized == true; //视频控制器是否加载 + double get normalRatio => 16 / 9; + + /// 实际播放比例:动漫和未初始化都用 16:9 兜底,否则用控制器报告的实际比例 + double get videoRatio => + (!isCartoon && isInited) ? playerCtr!.value.aspectRatio : normalRatio; + + StreamSubscription? _pauseVideoSub; + + @override + void onInit() { + loadData(); + // 耳机/蓝牙断开、来电中断时暂停,防止外放泄露 + _pauseVideoSub = eventBus.on((_) => playerCtr?.pause()); + super.onInit(); + } + + /// 支付分层首弹:不分新人/老人,全局只弹一次(只看是否弹过,不再判断配置卡) + void alertVipFirst() async { + if (await MineMsgProvider.isPayPopupShown()) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isDispose) return; + BuyVipAlert.show(videoId: videoModel?.id); + MineMsgProvider.markPayPopupShown(); + }); + } + + /// 新用户(newUnpay)用免费次数观看的视频,进播放页停留满5分钟弹一次付费引导。 + /// 仅首次进入页面计时,页面内切集/换源不重启(一次页面只弹一次);计时/校验/取消交给 TimedPopupManager。 + void _startFreeWatchTimer() { + TimedPopupManager().schedule( + type: TimedPopupType.freeWatchPay, + delay: const Duration(minutes: 5), + //到点校验:场景开关开着、仅长视频、新用户、未开会员、仍在用免费次数观看 + //按到点时在播的内容判断:中途切到动漫就不弹了 + canShow: () => + !isDispose && + !isCartoon && + !globalStore.isVIP && + GuideManager().canShow(GuideFreeTrialSheet.scene) && + MineMsgProvider().payTier?.status == PayTier.newUnpay && + FreePlayManager().useFreePlay(videoModel) == true && + GuideFreeTrialSheet + .isCountLow, //必须排在 useFreePlay 之后:它会先扣一次,扣完才是该显示的剩余数 + onShow: () async { + // 先备好卡数据再暂停,否则拉数据期间视频白停几百毫秒;场景没配卡就压根不弹,别白暂停一下 + if (await loadGuideCard(GuideFreeTrialSheet.scene) == null || isDispose) + return; + // 只暂停本来在播的:用户自己停在那看简介/评论时,弹完别擅自续播 + final wasPlaying = playerCtr?.value.isPlaying == true; + if (wasPlaying) + playerCtr?.pause(); // 图标由 VideoMenuView 的 Rx 自动跟,不用 update() + final goVip = await GuideFreeTrialSheet.show(); + // 跳了会员页 / 已退页 / 期间被硬拦截弹了蒙层,都不能恢复播放 + if (!wasPlaying || goVip || isDispose || isShowBuy.value) return; + playerCtr?.play(); // 不走 play():那是新一次播放,会多报一条 VIDEO_PLAY + }, + ); + } + + bool _payGuideShown = false; // 本页「付费前置提示」是否已弹过(一次页面只弹一次) + + /// 要花金币买或要开会员才能看完整片(不要求配了试看地址) + bool get _needPayGuide => videoStatus.isNeedPay; + + /// 能连续看的秒数:有试看片就是试看片长,否则是正片免费时长 + int get _freeSeconds => videoModel?.previewURL?.isNotEmpty == true + ? (playerCtr?.value.duration.inSeconds ?? 0) + : (videoModel?.freeTime ?? 0); + + /// 各类「整片免看」权益:免费次数 / 免费区 / 动漫整本已购 / 动漫免费章节 / VIP 金币限免。 + /// 命中任一说明用户本来就能看完整片,场景1、场景2 都不该打扰他。 + /// [_onProgress] 与场景1 共用,避免两边判断漂移。 + /// (useFreePlay 有扣次数副作用,但 _initPlayer 起手已调过一次,之后都走 usedIds 幂等返回) + bool get _hasFreeWatchRight { + if (FreePlayManager().useFreePlay(videoModel)) return true; + if (videoModel?.freeArea == true) return true; + if (cartoonModel?.mediaStatus?.hasPaid == true) + return true; // 动漫整本解锁, 都可以看, 不管vip权限 + if (isCartoon && + cartoonModel?.isFreeEpisodeNumber(currentEpisode?.episodeNumber) == + true) return true; + return isVipCoinFree; + } + + /// 起播前置引导(VIDEO_PREVIEW_END):需付费的视频起播 3 秒后前置弹一次购买引导 + /// (暂停,关掉后续播,一次进页面只弹一次)。 + /// 与超免费时长/试看结束的硬拦截(BuyVipAlert)都保留;本引导弹前会 pause, + /// 期间进度不走、硬拦截触发不了,两者天然错开,不靠 TimedPopupManager 互斥。 + /// 计的是起播后的墙钟 3 秒(不要求真播满 3 秒进度);暂停后再播会重新计时。 + void _startPayGuideTimer() { + if (_freeSeconds < 3) return; // 不足3秒:没等到提示就已被试看结束的硬拦截拦下了 + TimedPopupManager().schedule( + type: TimedPopupType.videoPayAhead, + delay: const Duration(seconds: 3), + //到点再校验一次:3秒内买了 / 开了会员 / 进了全屏都不弹(全屏弹会连横竖屏一起乱) + //_hasFreeWatchRight:能免费看完整片的别拿"只能看试看"的文案打扰他 + canShow: () => + !isDispose && + !_payGuideShown && + !isFullScreen && + _needPayGuide && + !_hasFreeWatchRight && + GuideManager().canShow(GuideScene.videoPreviewEnd), + onShow: () async { + // 到点还在播才弹(缓冲期 isPlaying 仍为 true,不违背"起播3秒")。 + // 挡掉:这3秒里手动暂停了 / 点推荐开了新视频页(先 pause 再 push,旧页没销毁、Timer 照跑) + if (playerCtr?.value.isPlaying != true) return; + _payGuideShown = true; + // 先备好卡数据再暂停,否则拉数据期间视频白停几百毫秒;场景没配卡就压根不弹,别白暂停一下 + if (await loadGuideCard(GuideScene.videoPreviewEnd) == null || + isDispose) return; + playerCtr?.pause(); // 图标由 VideoMenuView 的 Rx 自动跟,不用 update() + final goVip = await _showPayGuide(); + // 跳了会员页 / 已退页 / 期间被硬拦截弹了蒙层,都不能恢复播放 + if (goVip || isDispose || isShowBuy.value) return; + playerCtr?.play(); // 不走 play():那是新一次播放,会多报一条 VIDEO_PLAY + }, + ); + } + + /// 起播前置引导弹窗(VIDEO_PREVIEW_END)。返回 true = 点了开通并已跳会员页 + /// (试看结束/超时长已改弹 BuyVipAlert 硬拦截,不再共用这套文案) + /// 长视频不传 buttonText,走默认「立即开通 · 仅需¥价格」 + Future _showPayGuide() => GuideBottomSheet.showVideo(); + + bool _backGuideShown = false; // 本页「返回优惠挽留」是否已弹过(一次页面只弹一次) + + /// 是否允许直接退页。false=先弹返回挽留(VIDEO_BACK):长视频 + 场景开关允许 + 本页未弹过。 + /// 供 PopScope.canPop 与返回箭头共用,类型跟当前在播内容走 + bool get canPop => + _backGuideShown || + isCartoon || + !GuideManager().canShow(GuideScene.videoBack); + + /// VIDEO_BACK:长视频返回挽留。弹优惠倒计时,标记已弹并 update() 让 canPop 放行后续返回。 + /// 用户是按了返回才弹的,所以挽留失败(点X/点蒙层/被互斥挡下)就把这次返回执行完; + /// 点了「立即开通」已跳会员页则留在本页,从会员页回来还能接着看 + void showBackGuide() async { + if (_backGuideShown) return; + _backGuideShown = true; + update(); // 刷新 PopScope.canPop → 关掉弹窗后再次返回可直接退页 + var goVip = false; + // 取卡链路(签名/网络)抛异常绝不能让返回失灵:吞掉照样放行返回 + try { + await TimedPopupManager().trigger( + canShow: () => GuideManager().canShow(GuideScene.videoBack), + onShow: () async => goVip = + await GuideCountdownDialog.show(scene: GuideScene.videoBack), + ); + } catch (e) { + debugLog('showBackGuide', e.toString()); + } + if (!goVip && !isDispose) Get.back(); + } + + /// UI 返回箭头统一入口:可退直接退,否则弹返回挽留。 + void onBackPressed() { + if (canPop) { + Get.back(); + } else { + showBackGuide(); + } + } + + /// 拉取视频/动漫详情,写入观看历史,初始化播放 + /// - [newId] 非空:视频内切换集数 / 切换视频时复用本流程,传新 id + Future loadData({String? newId}) async { + if (isDispose) return; + _forceH264 = false; // 新视频复位:重新按设备能力试 265(运行时回退不跨视频) + final id = newId ?? videoModel?.id ?? vid ?? ""; + Object? detailError; // 诊断用:拉详情抛的异常 + try { + if (isCartoon == true) { + cartoonModel = await ACGService.getMediaInfo(id); + int curPage = cartoonModel?.episodeCurPage ?? 0; + final episodeModel = await ACGService.getChapterList( + id, + curPage + 1, + cartoonModel?.episodePageSize ?? 40, + sortType: cartoonModel?.isUpSort, + ); + cartoonModel?.episodeList = episodeModel?.list; + if (episodeModel?.list?.isNotEmpty != true) { + showToast("资源配置错误~"); + Get.back(); + return; + } + cartoonModel?.episodeCurPage = curPage + 1; + cartoonModel?.haxNextEpisode = episodeModel?.hasNext ?? true; + videoModel?.mediaInfo = cartoonModel; + currentEpisode = episodeModel?.list?.first; + MediaContent? mediaContent = + await ACGService.getMediaDetail(id: currentEpisode?.id ?? ""); + episodeModel?.list?.first.mediaContent = mediaContent; + videoModel = cartoonModel?.toVideoModel(currentEpisode) ?? VideoModel(); + } else { + videoModel = await VidService.getDetail(id); + } + isShowBuy.value = false; + update(); + } catch (e) { + detailError = e; // 留住异常给诊断用:为空说明接口通了但 data 是 null(解析失败/服务端空数据) + debugLog(e.toString()); + } + if (isDispose) return; // 拉详情期间可能已退页 + // 网络失败时 videoModel/mediaInfo 为 null,直接用 videoModel! 会崩("Null check operator"),兜底退出 + if (videoModel == null || (isCartoon && videoModel?.mediaInfo == null)) { + PlayDiagnose.report( + scene: '视频详情接口失败', + error: detailError ?? '接口无异常但返回 null(解析失败或服务端空数据)', + videoId: id, + videoTitle: isCartoon ? '动漫' : '视频', + ); + showToast("视频资源加载失败!"); + Get.back(); + return; + } + if (isCartoon == true) { + HistoryUtil.insert(videoModel!.mediaInfo!, MediaStyle.Cartoon); + } else { + HistoryUtil.insert(videoModel!, MediaStyle.Video); + } + _initPlayer(); + // 免费次数满5分钟引导 & 支付分层首弹:都只在首次进入页面触发,页面内切集/换源不重复(一次页面只弹一次) + if (newId == null) { + _startFreeWatchTimer(); + alertVipFirst(); + } + } + + /// 购买成功:标记已购 + 关蒙层 + 重建播放器切正片。 + /// 只改 hasPaid 不重建的话画面还停在预览片,必须重走 _initPlayer 重新选流,且从头播 + void onBuySuccess() { + if (isDispose) return; + isShowDialog = false; + isShowBuy.value = false; + videoModel?.vidStatus?.hasPaid = true; + startPlay(resetPos: true); + update(); + } + + /// CDN 路线切换:同视频换 URL 重新起播(由 VideoTabbarMenuWidget 选完线路回调) + void onSwitchLine() { + startPlay(); + update(); + } + + //hasAd: false 没有广告, true 广告还在 + void setAdShowing(bool hasAd) { + isShowAd = hasAd; + } + + /// 开始播放 + /// 注:贴片广告期间不直接 play,由广告结束回调触发本方法(见 video_page.dart 的 onFinish) + void play() { + if (isDispose || isShowAd) return; // 有贴片广告时不自动播,等广告结束回调 + playerCtr?.play(); + _startPayGuideTimer(); // 需付费的起播满3秒 → 前置购买引导(场景1);不满足条件的它自己会挡 + } + + /// 进入全屏(贴片广告中或控制器未就绪 → 不进) + void toFullPage() async { + if (isShowAd || playerCtr?.value.isInitialized != true) { + return; + } + isFullScreen = true; + await Get.to( + () => VideoFullPage(playCtr: playerCtr!, videoModel: videoModel)); + isFullScreen = false; + } + + /// 退出全屏 + void exitFullScreen() { + if (!isFullScreen) return; + isFullScreen = false; + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, + overlays: [SystemUiOverlay.bottom, SystemUiOverlay.top]); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + Get.back(); + } + + /// 点击广告跳金币充值,回到本页时如原本在播放则恢复播放 + void clickAdToVip() async { + bool isPlaying = playerCtr?.value.isPlaying ?? false; + playerCtr?.pause(); + await pushToWalletPage(tabPosition: 0); + if (isPlaying) play(); + await globalStore.updateUserInfo(); + if (globalStore.isVIP) { + update(); + } + } + + /// 初始化播放器:拉贴片广告 → 定广告展示 → 选流(中文/无码/预览/265) → init + 挂 [_onProgress]。 + /// 仅网络播放不走本地文件;[pos] >2 时续播到该秒(切线路场景) + void _initPlayer({int pos = 0, bool isRetry = false}) async { + if (isDispose) return; + FreePlayManager().useFreePlay(videoModel); + adsList = AdManager().adsByType(50); + //vip/已购视频不展示广告;否则配置了广告且配了关闭时间才展示 + final noAd = globalStore.isVIP || videoModel?.vidStatus?.hasPaid == true; + setAdShowing(!noAd && adsList.firstOrNull?.hasAdTime == true); + // 选流整合进 model.realPlayUrl;265 由设备硬解能力 + 本条是否已运行时回退共同决定 + videoUrl = videoModel?.realPlayUrl( + canPlayPreview: _isPreview(), + useH265: CodecSupport.useH265 && !_forceH264, + ); + // 选中的就是 265 源时当场记录(此刻 token/cdn 与拼 videoUrl 时一致,比较可靠) + final h265Url = videoModel?.realH265Url ?? ''; + isPlayingH265 = h265Url.isNotEmpty && videoUrl == h265Url; + playerCtr = PlayerFactory.network(videoUrl); // 仅网络播放,不走本地文件 + try { + await playerCtr?.initialize(); + if (isRetry) + confirmPlatformView(); // 仅重试成功(同视频 textureView 挂、platformView 放出)才落本地 + update(); + } catch (e) { + debugLog("视频初始化失败:$e play url = $videoUrl"); + playerCtr?.dispose(); // 初始化失败要释放,否则半初始化的解码器一直被占,多次后所有视频都加载失败 + playerCtr = null; + if (isDecoderError(e)) { + // 1) 先按芯片兼容机制内存切 platformView 重试(保留硬解/265,海思等渲染错常能救回,不误丢 265) + if (switchToPlatformView()) { + _initPlayer(pos: pos, isRetry: true); + return; + } + // 2) platformView 也救不了且当前放的是 265:本机永久禁用 265 并落本地,用 264 重试 + if (isPlayingH265 && CodecSupport.disableForDevice()) { + _initPlayer(pos: pos, isRetry: true); + return; + } + } + // 3) 非解码类错误(404/域名不通/非法 m3u8):只是这条 265 资源坏,与芯片无关 → 仅本条回退 264。 + // isRetry 透传而非写死 true:没切过 platformView 就别让 264 播通了去误标它 + if (isPlayingH265 && !_forceH264) { + _forceH264 = true; + debugLog("H.265 资源不可用,回退 264 兜底重试"); + _initPlayer(pos: pos, isRetry: isRetry); + return; + } + PlayDiagnose.report( + scene: '长视频播放失败', + error: e, + url: videoUrl, + videoId: videoModel?.id, + videoTitle: videoModel?.title, + isH265: isPlayingH265, + forceH264: _forceH264, + ); + showToast("视频资源加载失败!"); + return; + } + if (isDispose) { + playerCtr?.pause(); + playerCtr?.dispose(); + playerCtr = null; // 退页后才初始化完成:释放并置空,防止 UI 重建摸到已 dispose 的控制器 + return; + } + presaleProvider.useCoinPrivilege(videoModel); + playerCtr?.addListener(_onProgress); + int previewStart = videoModel?.previewStart ?? 0; + if (previewStart > 0) { + final canFree = FreePlayManager().useFreePlay(videoModel) || + videoModel?.freeArea == true; + // 免金币视频 / 有免费观看次数(vip视频):从头播,不需要 seekTo + // 需要付费/会员才能看的视频,跳到预览起始位置开始播放 + if (!canFree && videoStatus.isNeedPay) { + await playerCtr?.seekTo(Duration(seconds: previewStart)); + } + } else if (pos > 2) { + await playerCtr?.seekTo(Duration(seconds: pos)); + } + play(); + update(); + } + + /// 播放进度监听(每帧):有免看权益的全程不拦截; + /// 正片到免费时长弹购买,预览片播完回 0 + 暂停 + 弹窗 + void _onProgress() async { + // 265 运行时降级:播放器报错立即回退 264(不持久化) + if (isPlayingH265 && playerCtr?.value.hasError == true) { + _fallbackTo264(); + return; + } + int duration = playerCtr?.value.duration.inSeconds ?? 0; + if (_hasFreeWatchRight) return; // 免费次数/免费区/动漫已购/免费章节/VIP金币限免,整片随便看 + if (!_isPreview()) { + //每帧都走这里,videoStatus 是重算 getter,只读一次 + final status = videoStatus; + final inFreeTime = + videoModel?.isInFreeTime(playerCtr!.value.position.inSeconds) == true; + final isPlaying = playerCtr?.value.isPlaying == true; + if (inFreeTime || !isPlaying) return; + // 全屏中:仍处于免费时长则不打扰;否则需付费的视频在播放时先退出全屏,给后续弹窗腾出空间 + final wasFullScreen = isFullScreen; + if (wasFullScreen && status.isNeedPay) exitFullScreen(); + //超过免费时间:需金币购买 / 需开会员,都弹购买蒙层 + if (status.isNeedPay) { + showBuyMask(); + // 非会员且免费次数已用完的 VIP 片:蒙层之上直接弹开通弹窗 + // (金币片仍只显示蒙层,由蒙层引导金币解锁/观影券;动漫的弹窗在 showBuyMask 内部处理,别在这叠) + // showBuyMask 已 pause,下一帧 isPlaying=false 就提前 return,不会重复弹 + if (!isCartoon && status.isNeedVip) { + // 刚退全屏要等 pop 动画和转屏落定再弹:exitFullScreen 的 Get.back() 带动画, + // 此刻 push 会叠在正在 pop 的全屏页上,且弹窗高度取的是实时 Get.height,横屏下算出来是错的 + if (wasFullScreen) + await Future.delayed(const Duration(milliseconds: 350)); + if (!isDispose) BuyVipAlert.show(videoId: videoModel?.id); + } + } + } else { + //预览视频逻辑处理 + if (playerCtr!.value.position.inSeconds == duration - 1) { + await playerCtr!.seekTo(Duration.zero); + playerCtr?.pause(); + exitFullScreen(); + Future.delayed(const Duration(milliseconds: 200), () async { + if (isDispose) return; + if (videoModel?.coins != 0) { + showBuyMask(); + } else if (videoModel?.coins == 0 && videoModel?.freeArea != true) { + ///点击的必须弹出 + if (!isShowDialog) { + isShowDialog = true; + unawaited(showBuyMask()); // 只等蒙层显示,不等动漫购买弹窗关闭(与原逻辑一致) + isShowDialog = false; + await globalStore.updateUserInfo(); + //已开通 VIP → 重启播完整片;否则保持暂停(蒙层已显示,由蒙层引导开通), + //不能再 play() 恢复预览,否则"试看结束"蒙层还在、视频却在后面继续播 + if (globalStore.isVIP) { + startPlay(); + update(); + } else if (!isDispose) { + // 试看结束:所有非会员统一弹开通弹窗,under7/over7 原来弹吸底分层引导,现收敛成一个 + // 不走 TimedPopupManager:这是硬拦截,被互斥挡下就等于没弹 + await BuyVipAlert.show(videoId: videoModel?.id); + } + } + } + }); + } + } + } + + /// 265 播放中报错:仅本条回退 264 续播,不持久化。 + /// 回退后 isPlayingH265 自然为 false 不会重入,_forceH264 再兜住重建中的窗口期 + void _fallbackTo264() { + if (isDispose || !isPlayingH265 || _forceH264) return; + _forceH264 = true; // 本条视频后续重建都走 264 + debugLog( + "H.265 运行时报错,回退 264 续播 pos=${playerCtr?.value.position.inSeconds}"); + releasePlayer(); // 释放当前 → 下一帧按进度续播,选流因 _forceH264 走 264 + } + + /// 弹购买/VIP 蒙层(暂停 + 显示)。动漫 permission==1 走单集/全集购买(result 1单集 2全集), + /// 其他走 VIP 弹窗;长视频只暂停+显示,交给 VideoMaskBuyView + Future showBuyMask() async { + playerCtr?.pause(); + isShowBuy.value = true; + update(); + if (isCartoon) { + if (videoModel?.mediaInfo?.permission == 1) { + var result = await ComicBuyAlert.show(mediaInfo: videoModel?.mediaInfo); + + ///true表示支付成功 + if (result == 1 || result == 2) { + videoModel?.vidStatus?.hasPaid = true; + currentEpisode?.hasBuy = true; + if (result == 2) { + // 全集购买 + videoModel?.mediaInfo?.mediaStatus?.hasPaid = true; + } + playerCtr?.play(); + } + update(); + } else { + await BuyVipAlert.show(videoId: videoModel?.id); + } + } + } + + /// 是否走预览视频(短预告片) + /// 条件:用户没有免播次数 + 配了 previewURL + 视频需要付费/VIP + bool _isPreview() { + if (FreePlayManager().useFreePlay(videoModel) == true) return false; + return videoModel?.previewURL?.isNotEmpty == true && videoStatus.isNeedPay; + } + + /// 推荐区切内容(释放控制器 + 重拉详情起播,不开新页)。 + /// **必须按被点内容的 videoType 定接口**,用页面入参会串接口 → 「视频资源加载失败」 + Future switchVideo(VideoModel model) async { + videoModel = model; + isShowBuy.value = false; + isCartoon = model.videoType == 1; + if (!isCartoon) { + // 切到真人视频:清掉上一部动漫的章节态,避免选集/免费章节逻辑串到真人视频 + cartoonModel = null; + currentEpisode = null; + } + final old = playerCtr; + old?.pause(); + old?.removeListener(_onProgress); + WidgetsBinding.instance.addPostFrameCallback((_) => old?.dispose()); + loadData(newId: model.id); + playerCtr = null; + update(); + } + + /// 切视频或切 CDN 路线:[model] 非空=切新视频,为空=同视频换 URL;控制器为空走首次进入路径 + Future startPlay({VideoModel? model, bool resetPos = false}) async { + if (model != null) { + videoModel = model; + } + if (playerCtr == null) { + _initPlayer(); + } else { + releasePlayer(model: model, resetPos: resetPos); + update(); + } + } + + /// 动漫切换集数:同集 → toast 提示;不同集 → 释放当前 + 拉新集 mediaContent + 启动播放 + void switchEpisode(ComicChapterInfo episode) async { + if (episode.id == currentEpisode?.id) { + showToast("正在播放"); + return; + } + try { + currentEpisode = episode; + isShowBuy.value = false; + releasePlayer(rebuild: false); + update(); + currentEpisode?.mediaContent ??= + await ACGService.getMediaDetail(id: currentEpisode?.id ?? ""); + startPlay( + model: cartoonModel?.toVideoModel(currentEpisode) ?? VideoModel()); + } catch (e) { + debugLog(e); + } + } + + /// 释放播放器。[rebuild]=false 只释放(切集前清理);[model] 非空则切新视频,为空则按上次进度续播。 + /// dispose 走下一帧,避免在播放回调中销毁导致 use-after-free + void releasePlayer( + {VideoModel? model, bool rebuild = true, bool resetPos = false}) { + final old = playerCtr; + old?.pause(); + // resetPos:预览片→正片场景,两者时间轴不对应,不能沿用预览片进度,从头播 + final pos = resetPos ? 0 : (old?.value.position.inSeconds ?? 0); + old?.removeListener(_onProgress); + WidgetsBinding.instance.addPostFrameCallback((_) { + old?.dispose(); + if (isDispose || !rebuild) return; // 退页后别再重建播放器,否则野解码器泄漏 + if (model != null) { + isShowBuy.value = false; + update(); + loadData(newId: model.id); + } else { + _initPlayer(pos: pos); + } + }); + playerCtr = null; + } + + /// 页面销毁清理:取消事件订阅 → 非预览视频上报观看记录 → 释放播放器 + @override + void onClose() { + isDispose = + true; // 第一时间置位,让所有异步回调(initialize/postFrame/Future.delayed)能尽早 return + TimedPopupManager().cancel(TimedPopupType.freeWatchPay); + TimedPopupManager().cancel(TimedPopupType.videoPayAhead); + + playerCtr?.pause(); + playerCtr?.removeListener(_onProgress); + _pauseVideoSub?.cancel(); + if (playerCtr != null) { + bool isPreview = false; + if (videoModel?.previewURL?.isNotEmpty == true) { + if (playerCtr?.dataSource.contains(videoModel?.previewURL ?? "") == + true) { + isPreview = true; + } + } + if (!isPreview) { + try { + _reportPlay( + playerCtr?.value.position, playerCtr?.value.duration, videoModel); + } catch (e) { + debugLog(e); + } + } + } + playerCtr?.dispose(); + playerCtr = null; + tabCtr.dispose(); + super.onClose(); + } + + /// 上报播放记录(static:不碰实例状态,避免请求在飞时把已销毁的 logic 一起吊住) + static Future _reportPlay( + Duration? position, Duration? duration, VideoModel? video) async { + if (position == null || duration == null) return; + if (position.inMilliseconds < 1000) return; // 不足 1 秒不记 + if (video?.videoType == 1) return; // 动漫不走这个接口 + + // 播放器拿不到时长时退回视频配置的 playTime + var totalMs = duration.inMilliseconds; + if (totalMs <= 0) totalMs = (video?.playTime ?? 0) * 1000; + if (totalMs <= 0) return; // 时长未知,算不出进度 + + try { + await VidService.sendRecord( + video?.id ?? '', + playWay: video?.coins == 0 ? 0 : 1, + longer: position.inSeconds, + progress: (position.inMilliseconds / totalMs * 100).toInt(), + via: 1, + tagID: video?.tags?.firstOrNull?.id ?? '', + ); + } catch (e) { + debugLog('_reportPlay', 'error:$e'); + } + } +} diff --git a/lib/hj_page/video/video_page.dart b/lib/hj_page/video/video_page.dart new file mode 100644 index 0000000..a03702d --- /dev/null +++ b/lib/hj_page/video/video_page.dart @@ -0,0 +1,296 @@ +// ignore_for_file: use_build_context_synchronously, file_names + +import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/video/video_logic.dart'; +import 'package:hgdj/hj_page/video/view/video_ad_widget.dart'; +import 'package:hgdj/hj_page/video/view/video_mask_buy_view.dart'; +import 'package:hgdj/hj_page/video/view/video_menu_view.dart'; +import 'package:hgdj/hj_page/video/view/video_status_view.dart'; +import 'package:hgdj/hj_page/video/view/video_tabbar_menu_widget.dart'; +import 'package:hgdj/hj_page/video/view/vip_promo_banner.dart'; +import 'package:hgdj/hj_utils/codec_support.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; +import 'package:video_player/video_player.dart'; + +import '../../hj_model/video_model.dart'; +import '../../tools_base/widget/net_image_widget.dart'; +import '../comment/comment_views.dart'; +import 'cartoon/video_detail_cartoon_view.dart'; +import 'view/video_detail_view.dart'; + +class VideoPage extends StatefulWidget { + final bool isCartoon; + final VideoModel? model; + final String? id; + final String? videoCover; + + VideoPage({ + super.key, + this.model, + this.isCartoon = false, + this.id, + this.videoCover, + }); + + @override + State createState() => _VideoPageState(); +} + +class _VideoPageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + init: VideoLogic( + vid: widget.id ?? widget.model?.id, + isCartoon: widget.isCartoon, + ), + tag: uniqueTag, + builder: (logic) => PopScope( + // 场景3:长视频返回挽留。iOS 侧滑在 canPop=false 时会被整体禁用且不回调,没法用来弹窗, + // 故 iOS 恒可退、仅靠返回箭头(onBackPressed)弹挽留;Android 用 canPop 拦硬件返回键。 + canPop: GetPlatform.isIOS || logic.canPop, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + logic.showBackGuide(); + }, + child: Scaffold( + body: SafeArea( + child: Column( + children: [ + AspectRatio( + aspectRatio: logic.normalRatio, + child: _buildPlayContent(logic), + ), + if (logic.videoModel != null) ...[ + VipPromoBanner( + playCtr: logic.playerCtr, + videoModel: logic.videoModel, + ), + VideoTabbarMenuWidget( + logic.tabCtr, + model: logic.videoModel, + onSwitchLine: logic.onSwitchLine, + ), + Expanded(child: _buildTabDetailInfo(logic)), + ], + ], + ), + ), + ), + ), + ); + } + + /// 底部 TabBarView:第 0 页详情/推荐,第 1 页评论(与 VideoTabbarMenuWidget 共用 logic.tabCtr) + /// 注:选哪个详情视图、切视频走哪条接口,都以当前数据的 videoType 为准,别用页面入参 widget.isCartoon + Widget _buildTabDetailInfo(VideoLogic logic) { + final model = logic.videoModel; + final isCartoonVideo = model?.videoType == 1; + return TabBarView( + controller: logic.tabCtr, + children: [ + if (isCartoonVideo) + VideoDetailCartoonView( + model: model, + currentEpisode: logic.currentEpisode, + playCtr: logic.playerCtr, + vmCallback: logic.switchVideo, + episoCallback: logic.switchEpisode, + ).keepAlive + else + VideoDetailView( + model: model, + playCtr: logic.playerCtr, + vmCallback: logic.switchVideo, + ).keepAlive, + Container( + padding: const EdgeInsets.only(top: 12), + child: CommentView( + // key 跟着 id 走:页内切内容后强制重建,否则 GetBuilder 只在 initState 注册一次 controller, + // objId 变了评论区还是上一部的(推荐区靠 ValueKey('rec_$id') 解决的是同一个问题) + key: ValueKey('cmt_${model?.id}'), + model?.id ?? '', + objType: isCartoonVideo ? "cartoon" : "video", + ), + ).keepAlive, + ], + ); + } + + /// 播放器外层:监听 videoModel.id 变化,切换视频时触发 fade + scale 动画 + Widget _buildPlayContent(VideoLogic logic) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: ScaleTransition( + scale: Tween(begin: 0.9, end: 1.0).animate(anim), + child: child, + ), + ), + // KeyedSubtree 的 key 由 videoId 决定:id 变化 → AnimatedSwitcher 触发动画 + child: KeyedSubtree( + key: ValueKey(logic.videoModel?.id ?? 'empty'), + child: _buildPlayBody(logic), + ), + ); + } + + /// 播放器主体(视频画面 + 控制层 + 蒙层 + 贴片广告 + 返回按钮) + Widget _buildPlayBody(VideoLogic logic) { + return Container( + color: AppColors.primaryColor, + child: Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + onTap: () => _togglePlayPause(logic), + child: Container( + alignment: Alignment.center, + child: AspectRatio( + aspectRatio: logic.videoRatio, + child: logic.playerCtr != null + ? VideoPlayer(logic.playerCtr!) + : SizedBox(), + ), + ), + ), + if (logic.playerCtr?.value.isInitialized != true) + NetworkImageLoader( + imageUrl: logic.videoModel?.cover ?? widget.videoCover ?? "", + borderRadius: 0), + if (logic.playerCtr?.value.isInitialized == true) ...[ + Positioned( + top: 12, + right: 16, + child: VipFreeTipView( + key: ValueKey("status_top_${logic.videoModel?.id}"), + videoModel: logic.videoModel, + ), + ), + VideoMenuView( + key: ValueKey("VideoMenuView_${logic.videoModel?.id}"), + playCtr: logic.playerCtr!, + videoModel: logic.videoModel, + onBuyEvent: logic.showBuyMask, + onFullScreen: logic.toFullPage, + ), + ], + Positioned.fill(child: _bufferLoading(logic)), + Positioned.fill( + child: Obx( + () => Visibility( + visible: logic.isShowBuy.value, + child: VideoMaskBuyView( + logic.videoModel, + playCtr: logic.playerCtr, + // 购买成功要重建播放器换成正片,否则画面停在预览片(要退出重进才正常) + onBuySucc: () => logic.onBuySuccess(), + ), + ), + ), + ), + if (logic.isShowAd && logic.adsList.isNotEmpty) + VideoAdWidget( + adsInfos: logic.adsList, + showBackArrow: false, + onFinish: () { + logic.setAdShowing(false); + logic.update(); + logic.play(); + }, + onToVip: () async { + logic.clickAdToVip(); + }, + ), + Align( + alignment: Alignment.topLeft, + child: GestureDetector( + onTap: () => logic.onBackPressed(), + child: Container( + color: Colors.transparent, + width: 30, + height: 30, + alignment: Alignment.centerLeft, + margin: EdgeInsets.only(top: 10, left: 18.w), + child: Image.asset( + "back_circle.png".commonImgPath, + width: 24, + ), + ), + ), + ), + if (kDebugMode) _buildDebugCodecTag(logic), + ], + ), + ); + } + + /// 缓冲中的居中转圈。直接监听 playCtr(本身是 ValueNotifier), + /// 切视频/切线路换了控制器时 ValueListenableBuilder 会自动改订阅,无需手动 add/removeListener + Widget _bufferLoading(VideoLogic logic) { + final ctr = logic.playerCtr; + if (ctr == null) return const SizedBox(); + return ValueListenableBuilder( + valueListenable: ctr, + builder: (_, value, __) => value.isInitialized && value.isBuffering + ? const Center( + child: CupertinoActivityIndicator( + color: AppColors.actionRed, radius: 15)) + : const SizedBox(), + ); + } + + /// 【仅 debug】左下角编码排查标签:显示当前走 H265/H264,点击复制播放链接 + Widget _buildDebugCodecTag(VideoLogic logic) { + final isH265 = logic.isPlayingH265; + final deviceSupport = CodecSupport.useH265; // 设备硬解 265 探测结果 + return Positioned( + left: 10, + bottom: 35, + child: GestureDetector( + onTap: () { + final url = logic.videoUrl ?? ''; + Clipboard.setData(ClipboardData(text: url)); + showToast('已复制播放链接'); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: .6), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${isH265 ? "H265" : "H264"} 设备265:${deviceSupport ? "✓" : "✗"}', + style: TextStyle( + color: isH265 ? const Color(0xff4CAF50) : const Color(0xffFFC107), + fontSize: 11, + ), + ), + ), + ), + ); + } + + /// 点击播放器:播放中则暂停,否则播放(ctr 为 null 时不操作) + void _togglePlayPause(VideoLogic logic) { + final ctr = logic.playerCtr; + if (ctr == null) return; + if (ctr.value.isPlaying) { + ctr.pause(); + } else { + ctr.play(); + } + } +} diff --git a/lib/hj_page/video/video_player_alert.dart b/lib/hj_page/video/video_player_alert.dart new file mode 100644 index 0000000..a357263 --- /dev/null +++ b/lib/hj_page/video/video_player_alert.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_page/video/video_full_page.dart'; +import 'package:hgdj/hj_page/video/view/video_menu_view.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/hj_utils/video_view_type.dart'; +import 'package:hgdj/tools_base/event_bus/event_bus_util.dart'; +import 'package:hgdj/tools_base/event_bus/events.dart'; +import 'package:hgdj/tools_base/loading/loading_center_widget.dart'; +import 'package:video_player/video_player.dart'; + +import '../../tools_base/widget/net_image_widget.dart'; + +class VideoPlayerAlert extends StatefulWidget { + final String? movieUrl; + final String? imgCover; + + const VideoPlayerAlert(this.movieUrl, {this.imgCover, super.key}); + + @override + State createState() => _VideoPlayerAlertState(); +} + +class _VideoPlayerAlertState extends State { + VideoPlayerController? videoCtr; + StreamSubscription? pauseSub; + + @override + void initState() { + super.initState(); + // 耳机/蓝牙断开、来电中断时暂停,防止外放泄露 + pauseSub = eventBus.on((_) => videoCtr?.pause()); + WidgetsBinding.instance.addPostFrameCallback((_) => _initStatus()); + } + + void _initStatus({bool isRetry = false}) async { + videoCtr = PlayerFactory.network(widget.movieUrl); + try { + await videoCtr?.initialize(); + if (isRetry) + confirmPlatformView(); // 仅重试成功(同视频 textureView 挂、platformView 放出)才落本地 + if (mounted) { + videoCtr?.play(); + setState(() {}); + } + } catch (e) { + videoCtr?.dispose(); + // 首次芯片解码/渲染报错:仅内存切 platformView 重试,成功后才落本地 + if (isDecoderError(e) && switchToPlatformView()) { + _initStatus(isRetry: true); + } + } + } + + @override + void dispose() { + pauseSub?.cancel(); + videoCtr?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: EdgeInsets.zero, + backgroundColor: Colors.transparent, + child: SafeArea( + child: Stack( + fit: StackFit.expand, + children: [ + _buildContent(), + Positioned( + left: 0, + top: 0, + child: InkWell( + enableFeedback: false, + onTap: Get.back, + child: Container( + padding: EdgeInsets.only(left: 16, top: 6), + child: Image.asset('back_circle.png'.commonImgPath, + width: 24, height: 24), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildContent() { + // 未初始化完成:展示封面 + loading + if (videoCtr?.value.isInitialized != true) { + return Stack( + alignment: Alignment.center, + children: [ + if (widget.imgCover?.isNotEmpty == true) + NetworkImageLoader(imageUrl: widget.imgCover ?? ""), + LoadingCenterWidget(), + ], + ); + } + return Stack( + fit: StackFit.expand, + children: [ + Container( + alignment: Alignment.center, + width: screen.screenWidth, + child: AspectRatio( + aspectRatio: videoCtr!.value.aspectRatio, + child: VideoPlayer(videoCtr!), + ), + ), + VideoMenuView( + playCtr: videoCtr!, + isFull: true, + onFullScreen: () => + Get.to(() => VideoFullPage(playCtr: videoCtr!, isAutoV: false)), + ), + ], + ); + } +} diff --git a/lib/hj_page/video/view/long_video_status.dart b/lib/hj_page/video/view/long_video_status.dart new file mode 100644 index 0000000..9d730e5 --- /dev/null +++ b/lib/hj_page/video/view/long_video_status.dart @@ -0,0 +1,131 @@ +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/date_time_util.dart'; +import 'package:hgdj/tools_base/global_store/store.dart'; + +import '../../../hj_utils/free_play_manager.dart'; +import '../../pre_sale/pre_sale_provider.dart'; + +/// 长视频状态类型:逻辑判断一律用 [type],[LongVideoStatus.desc] 仅用于展示 +enum LongVideoStatusType { + none, // 无状态(自己的视频 / 免费次数内重复观看) + freeVideo, // 免费视频 + freeRemain, // 免费视频剩余N次 + vipFree, // 已享VIP免费特权 + purchased, // 已购买完整版 + coinFreeRemain, // 免费金币观影数剩余N次 + coinFree, // 已享金币视频免费特权 + skipPreview, // 跳过预览(含「N金币 跳过预览」) +} + +class LongVideoStatus { + final LongVideoStatusType type; + final String desc; // 展示文案 + final bool isNeedVip; // 需开会员才能看完整片 + final bool isNeedBuy; // 需花金币购买才能看完整片 + + const LongVideoStatus({ + this.type = LongVideoStatusType.none, + this.desc = '', + this.isNeedVip = false, + this.isNeedBuy = false, + }); + + /// 看完整片需要付费(开会员或买金币)。调用方别写 `isNeedVip || isNeedBuy`: + /// videoStatus 是个每次都重算的 getter,写两次就多算一遍 + bool get isNeedPay => isNeedVip || isNeedBuy; +} + +//复用多处的固定状态,抽出来免得同一串文案散落 +const _none = LongVideoStatus(); +const _coinFree = + LongVideoStatus(type: LongVideoStatusType.coinFree, desc: '已享金币视频免费特权'); +const _skipVip = LongVideoStatus( + type: LongVideoStatusType.skipPreview, desc: '跳过预览', isNeedVip: true); +const _skipBuy = LongVideoStatus( + type: LongVideoStatusType.skipPreview, desc: '跳过预览', isNeedBuy: true); + +/// 计算长视频的观看状态(展示文案 + 是否需付费/开会员) +/// +/// ⚠️ 分支顺序即业务优先级,命中即返回,不要随意调整前后 +/// ⚠️ 有副作用:内部 [FreePlayManager.useFreePlay] 会扣免费次数并上报, +/// 调用方一次 build 只调一次、结果存局部变量,别在同一段代码里反复读 +/// +/// 会员权益背景: +/// - 普通会员:VIP长视频、VIP抖音、社区VIP帖子、图集VIP、小说VIP、ACG VIP +/// - 高级会员:VIP + 金币视频(长视频/抖音)、社区VIP帖子、图集金币、小说金币、ACG;暗网视频和社区金币帖子除外 +/// - 超级会员:全网通,含暗网 + 社区金币帖子 +/// - 黄游单独购买 +LongVideoStatus longVideoStatus(VideoModel? model) { + if (model == null) return _none; + if (globalStore.isMe(model.publisher?.uid)) return _none; // 自己发布的视频 + + // 必须先读:下面 canFree 里的 useFreePlay 会扣次数,读晚了就少 1 + // (播放页进来时 _initPlayer 已扣过一次,所以这里读到的通常是「不含当前这次」的剩余数, + // 下面 +1 补回当前这次,和 GuideFreeTrialSheet.remainCount 同口径) + final freeCount = FreePlayManager().remain?.watchCount ?? 0; + final isVIP = globalStore.isVIP; + final coins = model.originCoins; + final isZeroCoin = coins == 0; // null 是后端没下发价格,不能当 0 处理 + final hasCoin = (coins ?? 0) > 0; + final hasPaid = model.vidStatus?.hasPaid == true; + final coinFreeLeft = coinFreeCount(model); + // late final = 惰性:条件短路时不触发;多处读取也只调一次(useFreePlay 有扣次数/发请求的副作用) + late final canFree = FreePlayManager().useFreePlay(model); + + if (model.freeArea == true) { + return const LongVideoStatus( + type: LongVideoStatusType.freeVideo, desc: '免费视频'); + } + if (!isVIP && freeCount >= 0 && isZeroCoin && canFree) { + return LongVideoStatus( + type: LongVideoStatusType.freeRemain, desc: '免费视频剩余${freeCount + 1}次'); + } + if (isVIP && isZeroCoin) { + // 交给 VipFreeTipView 显示,独立于操作台、3 秒后消失 + return const LongVideoStatus( + type: LongVideoStatusType.vipFree, desc: '已享VIP免费特权'); + } + if (hasPaid) { + return const LongVideoStatus( + type: LongVideoStatusType.purchased, desc: '已购买完整版'); + } + if (!isVIP && isZeroCoin && canFree) { + return _none; // 免费次数内看过的视频,重复观看仍免费(不显示文案) + } + if (coinFreeLeft >= 0) { + return LongVideoStatus( + type: LongVideoStatusType.coinFreeRemain, + desc: '免费金币观影数剩余: $coinFreeLeft次'); + } + // 往下不再判 !hasPaid:已购的上面就 return 了,走到这儿必定是未购 + if (hasCoin && globalStore.isAWVIP) { + // 暗网视频要顶级会员,否则仍需单独买 + if (model.isDarkTag && !globalStore.isVIPTopLevel) { + return LongVideoStatus( + type: LongVideoStatusType.skipPreview, + desc: '${model.coins}金币 跳过预览', + isNeedBuy: true); + } + return _coinFree; + } + // 二级会员 / 金币免费期内 / 本片金币已抵扣,都算已享金币免费特权 + if ((hasCoin && globalStore.isSuperVip) || + (isVIP && + DateTimeUtil.calTime3(globalStore.meInfo?.goldVideoFreeExpire) > 0 && + (coins ?? 50) <= 50) || + (isVIP && hasCoin && model.coins == 0)) { + return _coinFree; + } + if (!isVIP && isZeroCoin && !canFree) return _skipVip; // 免费次数已用完的 VIP 视频 + if (hasCoin) return _skipBuy; // 金币视频,单独买 + return _skipVip; // 价格没下发,兜底按开会员引导 +} + +/// 金币视频的权益免费观看次数;-1 表示不享受该权益 +int coinFreeCount(VideoModel? model) { + if (model == null || !model.isCoinVideo()) return -1; // 非金币视频 + if (model.freeArea == true) return -1; + if (globalStore.isVIP && model.coins == 0) return -1; // vip金币视频免看 + if (model.videoType == 1) return -1; // 动漫视频不享受金币免次数权益 + return presaleProvider.coinVideoFreeCount; +} diff --git a/lib/hj_page/video/view/video_ad_widget.dart b/lib/hj_page/video/view/video_ad_widget.dart new file mode 100644 index 0000000..bba984c --- /dev/null +++ b/lib/hj_page/video/view/video_ad_widget.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; + +import '../../../hj_model/splash/ads_model.dart'; +import '../../../tools_base/banner/ads_banner_widget.dart'; +import '../../../tools_base/global_store/store.dart'; + +/// 播放前贴片广告(Banner + 倒计时关闭)。 +/// 倒计时完或 VIP 关闭走 [onFinish],未到时间的非 VIP 走 [onToVip] 引导开通 +class VideoAdWidget extends StatefulWidget { + final List? adsInfos; // 轮播广告数据 + final bool showBackArrow; // 是否显示左上角返回箭头 + final VoidCallback? onFinish; // 广告结束/关闭回调 + final VoidCallback? onToVip; // 未到时间点关闭 → 引导开通 VIP + + const VideoAdWidget({ + super.key, + this.adsInfos, + this.showBackArrow = true, + this.onFinish, + this.onToVip, + }); + + @override + State createState() => _VideoAdWidgetState(); +} + +class _VideoAdWidgetState extends State { + // 倒计时用 ValueNotifier 局部刷新:每秒只重建关闭按钮那行字, + // 不再整棵树 setState(否则 Swiper/曝光检测/图片每秒白重建一次) + final _countdown = ValueNotifier(0); // 剩余强制观看秒数,<=0 表示可关闭 + Timer? _timer; + + // 是否可关闭:VIP / 倒计时结束 + bool get _canClose => globalStore.isVIP || _countdown.value <= 0; + + @override + void initState() { + super.initState(); + _countdown.value = widget.adsInfos?.firstOrNull?.watchTime ?? 0; + _timer = Timer.periodic(const Duration(seconds: 1), _onTick); + } + + @override + void dispose() { + _timer?.cancel(); + _countdown.dispose(); + super.dispose(); + } + + // 每秒递减,归零后停表 + void _onTick(Timer _) { + _countdown.value--; + if (_countdown.value <= 0) { + _timer?.cancel(); + _timer = null; + } + } + + // 关闭按钮文案 + String _closeLabel(int sec) { + if (sec <= 0) return '关闭'; + return globalStore.isVIP ? '${sec}s | 关闭广告' : '${sec}s | VIP可关闭广告'; + } + + // 点击关闭:可关闭则结束广告,否则引导开通 VIP + void _onCloseTap() { + if (!_canClose) { + widget.onToVip?.call(); + return; + } + _timer?.cancel(); + widget.onFinish?.call(); + } + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black, + child: Stack( + fit: StackFit.expand, + children: [ + // 广告轮播 Banner + AdsBannerWidget( + widget.adsInfos, + width: 329, + height: 88, + autoPlayMs: 2000, + isIndicatorBottomCenter: true, + ), + _closeBtn(), + if (widget.showBackArrow) _backBtn(), + ], + ), + ); + } + + // 右上角倒计时/关闭按钮(整棵树里只有这行字随倒计时刷新) + Widget _closeBtn() { + return Positioned( + top: 10, + right: 16, + child: GestureDetector( + onTap: _onCloseTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(20), + ), + child: ValueListenableBuilder( + valueListenable: _countdown, + builder: (_, sec, __) => Text( + _closeLabel(sec), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + ), + ); + } + + // 左上角返回 + Widget _backBtn() { + return Positioned( + top: 0, + left: 0, + child: GestureDetector( + onTap: () => Get.back(), + child: Padding( + padding: const EdgeInsets.all(12), + child: Image.asset("back_circle.png".commonImgPath, width: 24), + ), + ), + ); + } +} diff --git a/lib/hj_page/video/view/video_detail_bottom_menu.dart b/lib/hj_page/video/view/video_detail_bottom_menu.dart new file mode 100644 index 0000000..0dc25ca --- /dev/null +++ b/lib/hj_page/video/view/video_detail_bottom_menu.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/api_service/acg_service.dart'; +import 'package:hgdj/hj_utils/api_service/common_service.dart'; +import 'package:hgdj/hj_utils/api_service/mine_service.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:like_button/like_button.dart'; + +/// 底部操作栏:观看量 + 点赞/收藏/分享。计数与状态就地改在传入的 [VideoModel] 上, +/// 按 mediaInfo 是否为空区分漫画(video)/长视频(SP) 走不同接口。分享交外部处理 +class VideoDetailBottomMenu extends StatefulWidget { + final VideoModel? model; + final VoidCallback? onShare; + + const VideoDetailBottomMenu({super.key, this.model, this.onShare}); + + @override + State createState() => _VideoDetailBottomMenuState(); +} + +class _VideoDetailBottomMenuState extends State { + // ========== 数据 ========== + VideoModel? get videoModel => widget.model; + bool get isLike => videoModel?.vidStatus?.hasLiked ?? false; + bool get isCollect => videoModel?.vidStatus?.hasCollected ?? false; + bool get isCartoon => videoModel?.mediaInfo != null; // 有 mediaInfo 即漫画,否则长视频 + + // ========== 请求防重入 ========== + bool _isLiking = false; + bool _isCollecting = false; + + // 统一的灰色文案样式(观看量/点赞/收藏/分享) + static const _labelStyle = TextStyle(color: Color(0xff989898), fontSize: 12); + + /// 计数±1:原值为 null 时按操作前的状态兜底,保证取消后不会变成负数 + int _nextCount(int? cur, bool wasOn) => + (cur ?? (wasOn ? 1 : 0)) + (wasOn ? -1 : 1); + + // ========== 点赞 ========== + Future _onLike() async { + if (_isLiking) return isLike; + _isLiking = true; + try { + final preLike = isLike; // 请求前的状态,后续增减都以它为准 + final bizType = isCartoon ? "video" : "SP"; + if (preLike) { + await CommonService.cancelLike(videoModel?.id, bizType); + } else { + await CommonService.sendLike(videoModel?.id, bizType); + } + videoModel?.vidStatus?.hasLiked = !preLike; + videoModel?.likeCount = _nextCount(videoModel?.likeCount, preLike); + } catch (e) { + debugLog(e); + } + _isLiking = false; + if (mounted) setState(() {}); + return videoModel?.vidStatus?.hasLiked ?? false; + } + + // ========== 收藏 ========== + void _onCollect() async { + if (_isCollecting) return; + _isCollecting = true; + try { + final preCollect = isCollect; + if (isCartoon) { + preCollect + ? await ACGService.deleteBookshelf(videoModel?.id ?? "") + : await ACGService.addBookshelf(videoModel?.id ?? ""); + } else { + await MineService.postCollect(videoModel?.id, "SP", !preCollect); + } + videoModel?.collectCount = + _nextCount(videoModel?.collectCount, preCollect); + videoModel?.vidStatus?.hasCollected = !preCollect; + videoModel?.mediaInfo?.mediaStatus?.hasCollected = !preCollect; + videoModel?.mediaInfo?.countCollect = videoModel?.collectCount; + showToast(!preCollect ? "收藏成功" : "取消收藏成功"); + } catch (e) { + debugLog(e); + } + _isCollecting = false; + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text("${videoModel?.playCount?.countStr ?? ""}观看量", + style: _labelStyle), + ), + _likeItem(), + 12.sizeBoxW, + _collectItem(), + 12.sizeBoxW, + _shareItem(), + ], + ); + } + + Widget _likeItem() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + LikeButton( + isLiked: isLike, + size: 24, + likeBuilder: (isLiked) => Image.asset( + isLiked + ? "like_red.png".commonImgPath + : "video_like_grey.webp".videoPath, + ), + onTap: (_) => _onLike(), + ), + Text(videoModel?.likeCount?.countOr("点赞") ?? "点赞", style: _labelStyle), + ], + ); + } + + Widget _collectItem() { + return GestureDetector( + onTap: _onCollect, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 收藏状态切换时,图标 grey↔red 做 scale 弹出过渡 + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, anim) => + ScaleTransition(scale: anim, child: child), + child: Image.asset( + isCollect + ? 'collect_red.png'.commonImgPath + : 'collect_grey.png'.commonImgPath, + key: ValueKey( + isCollect), // key 按状态区分,AnimatedSwitcher 才当成新 child 触发动画 + width: 24, + ), + ), + 2.sizeBoxW, + Text(videoModel?.collectCount.countOr('收藏') ?? '收藏', + style: _labelStyle), + ], + ), + ); + } + + Widget _shareItem() { + return InkWell( + enableFeedback: false, + onTap: () => widget.onShare?.call(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset("share_grey.png".commonImgPath, width: 24, height: 24), + 2.sizeBoxW, + Text("分享", style: _labelStyle), + ], + ), + ); + } +} diff --git a/lib/hj_page/video/view/video_detail_view.dart b/lib/hj_page/video/view/video_detail_view.dart new file mode 100644 index 0000000..87d2330 --- /dev/null +++ b/lib/hj_page/video/view/video_detail_view.dart @@ -0,0 +1,260 @@ +import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/cartoon_media_info.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/home/tag/video_tag_page.dart'; +import 'package:hgdj/hj_page/video/view/video_detail_bottom_menu.dart'; +import 'package:hgdj/hj_utils/const.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:hgdj/tools_base/widget/keep_alive_widget.dart'; +import 'package:hgdj/tools_base/widget/shrink_wrap.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../alert/video/share_media_dialog.dart'; +import '../../../routers/jump_router.dart'; +import '../../cartoon/cartoon_recommend_page.dart'; + +/// 视频播放页数据详情 +class VideoDetailView extends StatefulWidget { + final VideoModel? model; + final VideoPlayerController? playCtr; + final Function(VideoModel model)? vmCallback; + + const VideoDetailView({ + super.key, + this.model, + this.playCtr, + this.vmCallback, + }); + + @override + State createState() => _VideoDetailViewState(); +} + +class _VideoDetailViewState extends State + with SingleTickerProviderStateMixin { + VideoModel? get videoModel => widget.model; + + // 视频(SP)推荐按当前视频第一个 tag 拉同类;tags 为空则全局推荐 + String? get _videoTagId => + videoModel?.tags?.isNotEmpty == true ? videoModel?.tags?.first.id : null; + + late final TabController tabCtr = TabController(length: 3, vsync: this); + + final List menuTitles = const ["视频推荐", "动漫推荐", "漫画推荐"]; + + @override + void dispose() { + tabCtr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ExtendedNestedScrollView( + onlyOneScrollInBody: true, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverToBoxAdapter( + child: Container( + margin: EdgeInsets.symmetric(horizontal: 12), + child: _buildVideoInfo(), + ), + ), + SliverToBoxAdapter( + child: Container( + margin: EdgeInsets.fromLTRB(12, 18, 12, 0), + child: + VideoDetailBottomMenu(model: videoModel, onShare: _onShare), + ), + ), + SliverToBoxAdapter( + child: Container( + margin: EdgeInsets.fromLTRB(12, 18, 12, 18), + child: 0.5.line, + ), + ), + // 视频播放页广告 + SliverToBoxAdapter( + child: AdsGridViewWidget( + 6, + padding: EdgeInsets.only(left: 12, bottom: 18), + accordingAdsType: true, + ), + ), + SliverToBoxAdapter(child: _buildTitleMenu()), + ]; + }, + body: TabBarView( + // 换播放源后 videoModel.id 变化 → 整个推荐区重建,按新视频刷新 + key: ValueKey('rec_${videoModel?.id}'), + controller: tabCtr, + children: [ + // 视频推荐 + CartoonRecommendPage( + mediaStyle: MediaStyle.Video, + videoTagId: _videoTagId, + crossAxisCount: 2, + childAspectRatio: 168 / 142, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + onVideoTap: _onVideoCellTap, + ).keepAlive, + // 动漫推荐 + CartoonRecommendPage( + mediaStyle: MediaStyle.Cartoon, + childAspectRatio: 111 / 174, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + onAcgTap: _onAcgCellTap, + ).keepAlive, + // 漫画推荐 + CartoonRecommendPage( + mediaStyle: MediaStyle.Comics, + childAspectRatio: 111 / 174, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + onAcgTap: _onAcgCellTap, + ).keepAlive, + ], + ), + ); + } + + /// 视频作品信息:标题 + 标签(原 VideoDetailInfoWidget 单处使用,已内联) + Widget _buildVideoInfo() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRichText(), + _buildVideoTags(), + ], + ); + } + + Widget _buildRichText() { + if (videoModel?.title?.trim().isNotEmpty != true) return const SizedBox(); + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + videoModel?.title?.trim() ?? "", + maxLines: 2, + style: const TextStyle( + color: Colors.white, + height: 1.5, + fontSize: 16, + fontWeight: FontWeight.w500), + ), + ); + } + + Widget _buildVideoTags() { + if (videoModel?.tags?.isNotEmpty != true) return const SizedBox(); + return Padding( + padding: const EdgeInsets.only(top: 12), + child: ShrinkWrap( + spacing: 0, + runSpacing: 6, + maxLines: 1, + children: videoModel!.tags!.map(_buildTagItem).toList(), + ), + ); + } + + Widget _buildTagItem(TagsBean tag) { + return InkWell( + enableFeedback: false, + onTap: () { + widget.playCtr?.pause(); + Get.to(() => VideoTagPage(tag), preventDuplicates: true); + }, + child: Container( + padding: EdgeInsets.fromLTRB(8, 2, 8, 2), + margin: EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: Color(0x0DFFFFFF), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + "#${tag.name}", + style: TextStyle( + color: Color(0x73ffffff), + fontSize: 12, + ), + ), + ), + ); + } + + /// 推荐 tab 标题栏(居中 TabBar + 渐变下划线,与漫画详情页一致) + Widget _buildTitleMenu() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TabBar( + controller: tabCtr, + isScrollable: true, + tabAlignment: TabAlignment.center, + padding: EdgeInsets.symmetric(horizontal: 8), + labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + labelColor: Color(0xE5FFFFFF), + unselectedLabelColor: Color(0x8CFFFFFF), + unselectedLabelStyle: + TextStyle(fontSize: 14, fontWeight: FontWeight.w400), + tabs: menuTitles + .map((e) => Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Text(e), + )) + .toList(), + indicator: CustomIndicator( + isGradient: true, + width: 13, + height: 3, + borderRadius: BorderRadius.circular(1.5)), + ), + ], + ); + } + + /// 分享:弹分享面板 + void _onShare() { + Get.dialog(ShareMediaDialog(videoModel: videoModel)); + } + + /// 视频推荐 cell 点击:无源地址/短片(<5min) → 暂停并 push 新页; + /// 同一视频 → toast;完整片 → 交 vmCallback 就地换源,不开新页 + void _onVideoCellTap(VideoModel acModel) { + if (acModel.sourceURL?.isNotEmpty != true) { + widget.playCtr?.pause(); + pushToVideoPage(videoModel: acModel); + return; + } + if (acModel.id == videoModel?.id) { + showToast("当前视频正在播放"); + return; + } + // 300 秒 = 5 分钟:短片当预览片,开新页播;长片直接在当前播放器替换源 + if ((acModel.playTime ?? 300) < 300) { + widget.playCtr?.pause(); + pushToVideoPage(videoModel: acModel); + } else { + widget.vmCallback?.call(acModel); + } + } + + /// 动漫/漫画 cell 点击:video 类型交 vmCallback 就地换源,其它跳漫画详情页 + void _onAcgCellTap(CartoonMediaInfo acModel) { + widget.playCtr?.pause(); + if (acModel.mediaType == 'video') { + // videoType=1 标记为动漫视频,让播放器走 cartoon 分支(区别于普通真人视频) + final vm = VideoModel(id: acModel.id) + ..cover = acModel.coverH + ..videoType = 1; + widget.vmCallback?.call(vm); + } else { + pushToCartoonPage(acModel); + } + } +} diff --git a/lib/hj_page/video/view/video_mask_buy_view.dart b/lib/hj_page/video/view/video_mask_buy_view.dart new file mode 100644 index 0000000..133337b --- /dev/null +++ b/lib/hj_page/video/view/video_mask_buy_view.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_page/video/view/long_video_status.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../alert/video/buy_vip_alert.dart'; +import '../../../hj_utils/pay/pay_manager.dart'; +import '../../../tools_base/global_store/store.dart'; +import '../../mine/mine_share/mine_share_page.dart'; +import '../../mine/mine_vip/widgets/coin_pay_bottom_sheet.dart'; +import '../../pre_sale/pre_sale_page.dart'; +import '../../pre_sale/pre_sale_provider.dart'; + +/// 试看结束遮罩:金币解锁 / 观影券解锁 / 开通VIP(预售) +class VideoMaskBuyView extends StatefulWidget { + final VideoModel? model; + final VideoPlayerController? playCtr; + final VoidCallback? onBuySucc; + + /// 遮罩主标题,默认「试看结束」 + final String maskTitle; + + const VideoMaskBuyView( + this.model, { + super.key, + this.playCtr, + this.onBuySucc, + this.maskTitle = '试看结束', + }); + + @override + State createState() => _VideoMaskBuyViewState(); +} + +class _VideoMaskBuyViewState extends State { + bool isBuying = false; // 下单中,防重复点击 + bool _isUnlocking = false; // 解锁流程中(含弹窗展示),防连点叠多个弹窗 + + bool get hasPresale => presaleProvider.isOpen; + + /// VIP 按钮文案:预售活动期内按预售流程走 + String get vipBtnTitle { + if (!hasPresale) return "开通VIP免费看"; + if (!presaleProvider.isPayFirst) return "开通预售免费看"; + return presaleProvider.canPayBalance + ? "支付尾款免费看" + : "请在${presaleProvider.startTimeMD}支付尾款"; + } + + /// 开通预售/VIP,回来后刷新按钮文案 + Future _onVipTap() async { + await (hasPresale + ? Get.to(() => PreSalePage()) + : BuyVipAlert.show(videoId: widget.model?.id)); + if (mounted) setState(() {}); + } + + /// 金币解锁:用本地缓存余额立刻决策,避免 await 刷余额拖慢弹窗; + /// 不够 → 马上弹充值;够 → 直接下单。服务端 8000 仍会兜底弹窗。 + Future _onCoinUnlock({bool useCoupon = false}) async { + if (isBuying || _isUnlocking) return; + _isUnlocking = true; + try { + if (!useCoupon) { + // 后台刷新,不阻塞本次点击 + globalStore.refreshWallet(); + final need = widget.model?.realCoins ?? widget.model?.coins ?? 0; + if ((globalStore.wallet?.amount ?? 0) < need) { + await CoinPayBottomSheet.show(); + if (mounted) setState(() {}); // 充值回来刷新余额展示 + return; + } + } + await _buy(useCoupon: useCoupon); + } finally { + _isUnlocking = false; + } + } + + /// 下单购买本片 + Future _buy({bool useCoupon = false}) async { + if (isBuying) return; + isBuying = true; + // 观影券抵扣:取能覆盖本片金币的券面额 + final couponNum = useCoupon + ? globalStore.meInfo?.couponGold(widget.model?.originCoins) + : null; + await PayManager().buy( + widget.model?.id, + ProductType.media, + source: 'video_mask', + goldVideoCouponNum: couponNum, + jumpWalletOnInsufficient: false, + onSuccess: (data) { + widget.model?.vidStatus?.hasPaid = true; + widget.playCtr?.play(); + globalStore.updateUserInfo(); + widget.onBuySucc?.call(); + }, + onFailure: (data) { + if (data?.code == 8000) CoinPayBottomSheet.show(); // 余额不足:不跳充值页,就地弹金币支付 + }, + ); + isBuying = false; + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + // 动漫单集购买走 ComicBuyAlert,这里不显示遮罩 + if (widget.model?.videoType == 1) return const SizedBox(); + // 只算一次:longVideoStatus 内部 useFreePlay 会扣免费次数,多次调用会重复扣 + final status = longVideoStatus(widget.model); + if (!status.isNeedPay) return const SizedBox(); + + final me = globalStore.meInfo; + return Container( + alignment: Alignment.center, + color: const Color.fromRGBO(0, 7, 18, 0.8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.maskTitle, + style: const TextStyle( + color: Colors.white, fontSize: 16, height: 1.5)), + 18.sizeBoxH, + const Text("开通VIP 全站视频免费看", + style: TextStyle(color: Colors.white, fontSize: 16)), + 18.sizeBoxH, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _btn( + title: status.isNeedBuy + ? "${widget.model?.coins ?? 0}金币解锁" + : "邀请得3日VIP", + colors: const [Color(0xffFFE8BE), Color(0xffE6B764)], + textColor: const Color(0xff694923), + onTap: () => status.isNeedBuy + ? _onCoinUnlock() + : Get.to(() => MineSharePage()), + ), + 18.sizeBoxW, + _btn( + title: vipBtnTitle, + colors: const [Color(0xffFF6E6E), Color(0xffFF4D4D)], + textColor: Colors.white, + onTap: _onVipTap, + ), + ], + ), + // 有观影券才展示券解锁入口 + if (me?.goldVideoCoupon?.isNotEmpty == true) + Padding( + padding: const EdgeInsets.only(top: 16), + child: GestureDetector( + onTap: () => _onCoinUnlock(useCoupon: true), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("使用观影券", + style: TextStyle(color: Colors.white, fontSize: 12)), + 3.sizeBoxW, + Text( + "x${me?.couponCount ?? 0}", + style: const TextStyle( + color: Color(0xffE5365C), fontSize: 12), + ), + ], + ), + ), + ), + ], + ), + ); + } + + /// 渐变胶囊按钮 + Widget _btn({ + required String title, + required List colors, + required Color textColor, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 6), + decoration: BoxDecoration( + gradient: LinearGradient(colors: colors), + borderRadius: BorderRadius.circular(30), + ), + child: Text(title, style: TextStyle(color: textColor, fontSize: 14)), + ), + ); + } +} diff --git a/lib/hj_page/video/view/video_menu_view.dart b/lib/hj_page/video/view/video_menu_view.dart new file mode 100644 index 0000000..4d922dd --- /dev/null +++ b/lib/hj_page/video/view/video_menu_view.dart @@ -0,0 +1,655 @@ +import 'dart:async'; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/toast.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../hj_utils/buy_util.dart'; +import '../../../hj_utils/date_time_util.dart'; +import '../../../hj_utils/free_play_manager.dart'; +import '../../../tools_base/global_store/store.dart'; +import '../../pre_sale/pre_sale_provider.dart'; +import '../../short_video/view/video_progress_widget.dart'; +import 'long_video_status.dart'; +import 'video_status_view.dart'; + +//播放器控制层:操作台显隐、进度拖动、快进快退、倍速面板、长按倍速 +class VideoMenuView extends StatefulWidget { + final VideoPlayerController playCtr; + final bool isFull; + final VideoModel? videoModel; + final VoidCallback? onFullScreen; //点全屏按钮 + final VoidCallback? onBuyEvent; //点状态角标去购买 + + const VideoMenuView({ + super.key, + required this.playCtr, + this.isFull = false, + this.videoModel, + this.onFullScreen, + this.onBuyEvent, + }); + + @override + State createState() => _VideoMenuViewState(); +} + +class _VideoMenuViewState extends State { + //倍速档位 + static const speeds = [0.5, 1.0, 1.5, 2.0]; + + Timer? _hideTimer; //操作台3秒自动隐藏定时器 + Timer? _skipTimer; //快进/快退提示1秒消失定时器 + + bool _showMenu = true; //操作台是否显示 + bool _showSpeed = false; //倍速面板是否展开 + bool _showSeekText = false; //是否显示中间拖动时间提示 + bool _skipVisible = false; //快进/快退提示是否可见(驱动显隐动画) + int _skipDir = 0; // 1快进, -1 快退(仅记方向,淡出期间保留) + bool _isLongPress = false; // 长按2倍速 + bool _isInited = false; //初始化仅一次的闩;中央图标 Obx 用到它但不订阅,靠 listener 里 setState 重建传导 + + Duration? _seekPos; //拖动中的目标进度 + bool _wasPlaying = false; //拖动前是否在播放(松手后恢复) + (Duration?, Duration?) _seekRange = (null, null); //试看可拖区间,每次 build 重算一次 + + // playCtr 高频状态用 Rx:listener 直接赋值(Rx 自带去重、同值不通知),对应部位用 Obx 局部刷新—— + // 免去手动维护比较字段,也不再播放时每帧全量 setState + final _playing = true.obs; //播放/暂停(按钮图标) + final _buffering = false.obs; //缓冲中(中央 loading 显隐) + final _posSec = 0.obs; //当前秒(试看锁定按秒判断) + + VideoPlayerController get playCtr => widget.playCtr; + + //当前播放倍速 + double get curSpeed => playCtr.value.playbackSpeed; + + /// 当前播的是预览片(未解锁 + 有预览地址) + bool get isPreview { + final model = widget.videoModel; + if (FreePlayManager().useFreePlay(model)) return false; + return model?.previewURL?.isNotEmpty == true && + (needVip(model) || needCoin(model)); + } + + /// 未购买/未开通 VIP 时,松手后超出试看区间则回弹到边界 + bool get _shouldLimitSeek { + final model = widget.videoModel; + if (model?.mediaInfo?.isCartoonFreeEpisode == true) + return false; // 动漫免费集:整条进度可拖,不锁试看区间 + if (FreePlayManager().useFreePlay(model)) return false; + if (model?.freeArea == true) return false; + if (model?.vidStatus?.hasPaid == true) return false; + if (isPreview) return false; + if (coinFreeCount(model) >= 0) return false; + if (model?.isCoinVideo() == true) { + if (globalStore.isVIP && model?.coins == 0) return false; + if (model?.videoType != 1 && presaleProvider.coinVideoFreeCount >= 0) + return false; + } + final status = longVideoStatus(model); + return status.isNeedPay; + } + + @override + void initState() { + super.initState(); + playCtr.addListener(_onPlayerTick); + _wakeMenu(); + } + + @override + void dispose() { + _hideTimer?.cancel(); + _skipTimer?.cancel(); + playCtr.removeListener(_onPlayerTick); + _playing.close(); + _buffering.close(); + _posSec.close(); + super.dispose(); + } + + //试看可拖动区间 [min,max]:仅 _shouldLimitSeek 时生效,否则 (null,null) 不限制 + (Duration?, Duration?) _calcSeekRange() { + if (!_shouldLimitSeek) return (null, null); + final start = widget.videoModel?.previewStart ?? 0; + final free = widget.videoModel?.freeTime ?? 0; + return (Duration(seconds: start), Duration(seconds: start + free)); + } + + /// 试看锁定:需付费(区间非空)且 [sec] 已超出免费试看区间 → 禁止拖动 + bool _isSeekLocked(int sec) => + _seekRange.$1 != null && widget.videoModel?.isInFreeTime(sec) != true; + + /// 松手落点:先夹到 [0,总时长],再夹回试看区间边界 + Duration _snapSeek(Duration target) { + var ms = target.inMilliseconds; + final totalMs = playCtr.value.duration.inMilliseconds; + if (ms < 0) ms = 0; + if (totalMs > 0 && ms > totalMs) ms = totalMs; + var result = Duration(milliseconds: ms); + final (min, max) = _seekRange; + if (min != null && result < min) { + result = min; + } else if (max != null && result > max) { + result = max; + } + return result; + } + + //整屏横滑:按位移换算目标进度(只更新预览,松手才真 seek) + void _dragSeek(Offset delta) { + if (_seekPos == null || !playCtr.value.isInitialized) return; + final totalMs = playCtr.value.duration.inMilliseconds; + var ms = _seekPos!.inMilliseconds + (800 * delta.dx).toInt(); + if (ms < 0) { + ms = 0; + } else if (ms > totalMs) { + ms = totalMs; + } + _seekPos = Duration(milliseconds: ms); + if (mounted) setState(() {}); + } + + //唤出操作台并起3秒自动隐藏;skipHint 时额外起1秒快进/快退提示消失定时器 + void _wakeMenu({bool skipHint = false}) { + if (!mounted) return; + _hideTimer?.cancel(); + _showMenu = true; + setState(() {}); + if (skipHint && _skipTimer == null) { + _skipTimer = Timer(const Duration(seconds: 1), () { + _skipVisible = false; + _skipTimer = null; + if (mounted) setState(() {}); + }); + } + _hideTimer = Timer(const Duration(seconds: 3), () { + _showMenu = false; + _showSpeed = false; + _skipVisible = false; + if (mounted) setState(() {}); + }); + } + + //点击播放器:菜单已显示则收起(倍速面板展开时先收面板),否则唤出并重新计时 + void _toggleMenu() { + if (!_showMenu) { + _playing.value = playCtr.value.isPlaying; + _wakeMenu(); + return; + } + if (_showSpeed) { + _showSpeed = false; + _wakeMenu(); + return; + } + _showMenu = false; + _hideTimer?.cancel(); + setState(() {}); + } + + void _setPlay(bool play) { + _playing.value = play; + play ? playCtr.play() : playCtr.pause(); + } + + //双击左右1/3区域:快退/快进10秒 + void _onDoubleTap(TapDownDetails details) { + final dx = details.globalPosition.dx; + final w = screen.screenWidth; + if (dx > w * 2 / 3) { + _skip(true); + } else if (dx < w / 3) { + _skip(false); + } + } + + void _skip(bool forward) { + _skipDir = forward ? 1 : -1; + _skipVisible = true; + _wakeMenu(skipHint: true); + const step = Duration(seconds: 10); + final pos = playCtr.value.position; + playCtr.seekTo(_snapSeek(forward ? pos + step : pos - step)); + } + + void _onPlayerTick() { + if (!mounted) return; + final v = playCtr.value; + // Rx 自带去重,对应 Obx 自动局部刷新;进度条自己监听 controller,这里只把本组件要用的状态喂给 Rx, + // 不再每帧全量 setState、也不用手动比较 + _playing.value = v.isPlaying; + _buffering.value = v.isBuffering; + _posSec.value = v.position.inSeconds; // 仅供试看锁定按秒判断 + // 初始化仅发生一次:触发一次整体刷新,让 menuVisible 等非 Obx 部分更新 + if (v.isInitialized && !_isInited) { + _isInited = true; + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final menuVisible = + _showMenu || !_isInited || _isLongPress; //操作台/未初始化/长按倍速时可见 + //试看区间每帧只算一次:_shouldLimitSeek 内含 useFreePlay 等带副作用的判断,不能每次拖动都重跑 + _seekRange = _calcSeekRange(); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggleMenu, + onHorizontalDragStart: (_) { + //试看结束/未初始化禁止拖动 + if (_isSeekLocked(playCtr.value.position.inSeconds) || + !playCtr.value.isInitialized) return; + _wasPlaying = playCtr.value.isPlaying; + if (_wasPlaying) playCtr.pause(); + _seekPos = playCtr.value.position; + _showSeekText = true; + setState(() {}); + }, + onHorizontalDragUpdate: (details) { + if (_isSeekLocked(playCtr.value.position.inSeconds) || + !playCtr.value.isInitialized) return; + _wakeMenu(); + _dragSeek(details.delta); + }, + onHorizontalDragEnd: (_) async { + if (_seekPos != null) { + final target = _snapSeek(_seekPos!); + _seekPos = target; + await playCtr.seekTo(target); + } + _seekPos = null; + if (_wasPlaying) playCtr.play(); + _showSeekText = false; + setState(() {}); + _wakeMenu(); + }, + onHorizontalDragCancel: () { + _showSeekText = false; + setState(() {}); + }, + onDoubleTapDown: (detail) { + _showSpeed = false; + _onDoubleTap(detail); + }, + onLongPressStart: (_) { + showToast("长按不动,2X倍速播放", gravity: ToastGravity.top); + _isLongPress = true; + _showSpeed = false; + _showSeekText = false; + setState(() {}); + _wakeMenu(); + playCtr.setPlaybackSpeed(2.0); + }, + //抬手恢复1倍速(onLongPressUp 与 onLongPressEnd 必然同时触发,留一个即可) + onLongPressEnd: (_) { + _wakeMenu(); + _isLongPress = false; + playCtr.setPlaybackSpeed(1.0); + }, + child: IgnorePointer( + //隐藏时屏蔽点击 + ignoring: !menuVisible, + child: AnimatedOpacity( + //操作台显隐淡入淡出 + duration: const Duration(milliseconds: 200), + opacity: menuVisible ? 1 : 0, + child: SafeArea( + //全屏时操作层避开左右刘海与底部 home 条,避免按钮贴边误触 + left: widget.isFull, + right: widget.isFull, + top: false, + bottom: widget.isFull, + child: Stack( + fit: StackFit.expand, + alignment: Alignment.center, + children: [ + //状态角标:右上角,跟随操作台一起显隐 + Positioned( + top: 12, + right: 16, + child: VideoStatusView( + model: widget.videoModel, onBuyEvent: widget.onBuyEvent), + ), + Positioned(bottom: 0, left: 0, right: 0, child: _bottomMenu()), + Obx(() { + // 先读 Rx 再判断:_isInited 为 false 时 || 会短路,Rx 读不到会触发 Obx “未订阅” 报错 + final buffering = _buffering.value; + final playing = _playing.value; + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) => ScaleTransition( + scale: animation, + child: FadeTransition(opacity: animation, child: child), + ), + child: (!_isInited || buffering || playing) + ? const SizedBox(key: ValueKey('playIconEmpty')) + : _playIcon(), + ); + }), + if (_showSeekText) _seekText(), + //倍速面板:从右滑入 / 向右滑出(隐藏时移出屏外并屏蔽点击) + AnimatedPositioned( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + top: 0, + bottom: 0, + right: _showSpeed ? 0 : -150, + child: IgnorePointer( + ignoring: !_showSpeed, child: _speedPanel()), + ), + if (_skipDir != 0) + Positioned( + left: (_skipDir == 1) ? null : 0, + right: (_skipDir == 1) ? 0 : null, + child: IgnorePointer( + child: AnimatedScale( + scale: _skipVisible ? 1 : 0.85, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + child: AnimatedOpacity( + opacity: _skipVisible ? 1 : 0, + duration: const Duration(milliseconds: 220), + child: Container( + color: Colors.black.withValues(alpha: 0.5), + padding: EdgeInsets.fromLTRB( + (_skipDir == 1) ? 12 : 24, + 12, + (_skipDir == 1) ? 24 : 12, + 12), + child: _skipHint(), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + //底部操作条:播放/暂停 + 进度条 + 倍速 + 全屏 + Widget _bottomMenu() { + final (minSeek, maxSeek) = _seekRange; + return Container( + color: const Color(0xff04040a).withValues(alpha: 0.4), + //全屏底部加留白,避免滑杆贴屏幕边缘(Android 手势区)不好滑 + padding: EdgeInsets.only(bottom: widget.isFull ? 10 : 0), + child: SizedBox( + height: 34, + child: Row( + children: [ + InkWell( + enableFeedback: false, + onTap: () { + if (!playCtr.value.isInitialized) return; + _setPlay(!playCtr.value.isPlaying); + _wakeMenu(); + }, + child: Container( + height: 32, + width: 36, + padding: const EdgeInsets.symmetric(vertical: 5), + alignment: Alignment.center, + child: Obx(() { + final showPause = _playing.value; //播放中显示暂停图标 + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) => ScaleTransition( + scale: animation, + child: FadeTransition(opacity: animation, child: child), + ), + child: Image.asset( + showPause + ? "pause_icon.webp".videoPath + : "play.webp".videoPath, + key: ValueKey(showPause), + width: 20.w, + ), + ); + }), + ), + ), + Expanded( + // enableSeek 随 _posSec(秒级)局部刷新:试看到点即禁止拖动滑杆 + child: Obx(() { + // 先读 Rx:区间为 null 时 && 会短路,_posSec.value 读不到会触发 Obx “未订阅” 报错 + final posSec = _posSec.value; + return VideoProgressWidget( + padding: EdgeInsets.zero, + controller: playCtr, + previewSeek: _seekPos, //整屏横滑拖动时,进度条 thumb/时间跟着 _seekPos 走 + enableSeek: !_isSeekLocked(posSec), //试看结束禁止拖动滑杆 + minSeekDuration: minSeek, + maxSeekDuration: maxSeek, + skipCallback: (duration) { + _seekPos = duration; + _showSeekText = true; + _wakeMenu(); + }, + gestureCallback: (value) { + if (value) { + _hideTimer?.cancel(); + } else { + _seekPos = null; //滑杆拖动结束清预览,避免 previewSeek 残留 + _showSeekText = false; + _wakeMenu(); + } + }, + ); + }), + ), + InkWell( + enableFeedback: false, + onTap: () { + _showSpeed = !_showSpeed; + _wakeMenu(); + }, + child: const Padding( + padding: EdgeInsets.fromLTRB(4, 6, 6, 6), + child: Text("倍速", + style: TextStyle(color: Colors.white, fontSize: 14)), + ), + ), + InkWell( + enableFeedback: false, + onTap: () => widget.onFullScreen?.call(), + child: Container( + height: 26, + width: 26, + margin: const EdgeInsets.only(right: 6), + child: Image.asset("full_icon.png".videoPath, + width: 26, height: 26), + ), + ), + ], + ), + ), + ); + } + + //倍速面板 + Widget _speedPanel() { + //空手势:吞掉面板上的横向拖动,避免穿透到底层触发视频快进/快退 + return GestureDetector( + onHorizontalDragStart: (_) {}, + onHorizontalDragUpdate: (_) {}, + onHorizontalDragDown: (_) {}, + onHorizontalDragCancel: () {}, + onHorizontalDragEnd: (_) {}, + child: ClipRRect( + borderRadius: const BorderRadius.horizontal(left: Radius.circular(12)), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), + child: Container( + width: 113, + color: const Color(0xff1E1F1E).withValues(alpha: 0.6), + child: ListView.separated( + padding: EdgeInsets.zero, + itemCount: speeds.length, + separatorBuilder: (_, __) => Container( + height: 1, color: Colors.white.withValues(alpha: 0.05)), + itemBuilder: (_, i) => _speedItem(speeds[i]), + ), + ), + ), + ), + ); + } + + Widget _speedItem(double speed) { + final isSelected = curSpeed == speed; + return InkWell( + enableFeedback: false, + onTap: () { + if (!isSelected) playCtr.setPlaybackSpeed(speed); + _showSpeed = false; + setState(() {}); + }, + child: Container( + height: 42, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 33), + child: Text( + "$speed倍", + style: TextStyle( + color: isSelected ? const Color(0xffF9C089) : Colors.white, //选中档金色 + fontSize: 14, + ), + ), + ), + ); + } + + //中央播放按钮(暂停且非缓冲时才出现) + Widget _playIcon() { + return Center( + key: const ValueKey('playIcon'), + child: GestureDetector( + behavior: HitTestBehavior.deferToChild, + onTap: () { + _wakeMenu(); + _setPlay(true); + }, + child: Image.asset('circle_play.webp'.videoPath, width: 40, height: 40), + ), + ); + } + + //拖动中的时间提示:当前 / 总时长 + Widget _seekText() { + final showDuration = _seekPos ?? playCtr.value.position; + return Container( + padding: const EdgeInsets.only(bottom: 8), + alignment: Alignment.center, + child: Column( + children: [ + //顶部偏移叠加安全区高度,避免竖屏被刘海/灵动岛遮挡 + //注意:必须用 context 的 MediaQuery(外层 SafeArea 已吃掉 top 时这里就该是 0),Get.mediaQuery 拿的是根节点值会多顶 47 + ((widget.isFull ? 24 : 12) + MediaQuery.of(context).padding.top) + .sizeBoxH, + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + //半透明黑底:亮色画面上也能看清时间 + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + DateTimeUtil.formatDuration(showDuration) ?? "", + style: const TextStyle( + color: AppColors.actionRed, fontSize: 14), //主题黄 + ), + if (playCtr.value.isInitialized) + Text( + " /${DateTimeUtil.formatDuration(playCtr.value.duration)}", + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + ), + ], + ), + ); + } + + /// 快进/快退提示:文字 + 流动箭头(替代静态 >> <<) + Widget _skipHint() { + final isForward = _skipDir == 1; + final text = Text( + isForward ? "快进10秒" : "快退10秒", + style: + TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 13), + ); + final arrows = _SeekArrows(forward: isForward); + return Row( + mainAxisSize: MainAxisSize.min, + children: + isForward ? [text, 4.sizeBoxW, arrows] : [arrows, 4.sizeBoxW, text], + ); + } +} + +/// 快进/快退的流动箭头动画:3 个三角箭头按相位依次点亮,形成流动感(替代静态 >> <<) +class _SeekArrows extends StatefulWidget { + final bool forward; // true=快进(▶ 向右),false=快退(◀ 向左) + const _SeekArrows({required this.forward}); + + @override + State<_SeekArrows> createState() => _SeekArrowsState(); +} + +class _SeekArrowsState extends State<_SeekArrows> + with SingleTickerProviderStateMixin { + late final AnimationController _ctr = AnimationController( + vsync: this, duration: const Duration(milliseconds: 900)) + ..repeat(); + + @override + void dispose() { + _ctr.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final arrow = widget.forward + ? const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 18) + : const RotatedBox( + quarterTurns: 2, + child: + Icon(Icons.play_arrow_rounded, color: Colors.white, size: 18), + ); + return AnimatedBuilder( + animation: _ctr, + builder: (_, __) => Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (index) { + // 波沿方向流动:快进 左→右、快退 右→左 + final i = widget.forward ? index : (2 - index); + final phase = (_ctr.value + i / 3.0) % 1.0; + final opacity = + 0.25 + 0.75 * (1 - (2 * phase - 1).abs()); // 三角波 0.25~1.0 + // 压窄每个箭头的占位宽度,让三角波间距更紧凑 + return SizedBox( + width: 10, child: Opacity(opacity: opacity, child: arrow)); + }), + ), + ); + } +} diff --git a/lib/hj_page/video/view/video_status_view.dart b/lib/hj_page/video/view/video_status_view.dart new file mode 100644 index 0000000..17063cb --- /dev/null +++ b/lib/hj_page/video/view/video_status_view.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/video_model.dart'; + +import 'long_video_status.dart'; + +const _tipPadding = EdgeInsets.fromLTRB(12, 3, 12, 3); +const _tipBg = BoxDecoration( + color: Color(0x99000000), + borderRadius: BorderRadius.all(Radius.circular(3)), +); +const _tipStyle = TextStyle(color: Colors.white, fontSize: 12); + +/// 「已享VIP免费特权」专用提示,与 [VideoStatusView] 同位置(top 12/right 16)分工: +/// 那个在操作台内随其显隐,本 view 挂在 page 的 Stack 上不随操作台走,**必须自带 3 秒定时器**自己消失 +class VipFreeTipView extends StatefulWidget { + final VideoModel? videoModel; + + const VipFreeTipView({super.key, this.videoModel}); + + @override + State createState() => _VipFreeTipViewState(); +} + +class _VipFreeTipViewState extends State { + bool _isShowTip = true; + Timer? _timer; + + /// 3 秒后隐藏提示。不在 initState 起:详情是异步加载的,首帧状态未必是 vipFree, + /// 要等状态真变成 vipFree 那次 build 才开始计时;`??=` 保证 build 多次也只起一个 + void _startHideTimer() { + _timer ??= Timer(const Duration(seconds: 3), () { + if (mounted) setState(() => _isShowTip = false); + }); + } + + @override + Widget build(BuildContext context) { + if (!_isShowTip) return const SizedBox(); + // 仅「已享VIP免费特权」走本 view,3 秒后自动隐藏;其余状态归 VideoStatusView + if (longVideoStatus(widget.videoModel).type != + LongVideoStatusType.vipFree) { + return const SizedBox(); + } + _startHideTimer(); + return Container( + padding: _tipPadding, + decoration: _tipBg, + child: const Text('已享VIP免费特权', style: _tipStyle), + ); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} + +/// 播放器操作台内的状态角标:显示状态文案,点击走购买/开会员 +/// vipFree 不在此显示(归 [VipFreeTipView]),动漫的「跳过预览 / 金币免费特权」也不显示 +class VideoStatusView extends StatelessWidget { + final VideoModel? model; + final Function? onBuyEvent; + + const VideoStatusView({super.key, this.model, this.onBuyEvent}); + + @override + Widget build(BuildContext context) { + if (model == null) return const SizedBox.shrink(); + final status = longVideoStatus(model); + final isCartoon = model?.videoType == 1; + final hideForCartoon = isCartoon && + (status.type == LongVideoStatusType.skipPreview || + status.type == LongVideoStatusType.coinFree); + if (hideForCartoon || + status.type == LongVideoStatusType.none || + status.type == LongVideoStatusType.vipFree) { + return const SizedBox(); + } + return GestureDetector( + onTap: () => onBuyEvent?.call(), + child: Container( + padding: _tipPadding, + decoration: _tipBg, + child: Text(status.desc, style: _tipStyle), + ), + ); + } +} diff --git a/lib/hj_page/video/view/video_tabbar_menu_widget.dart b/lib/hj_page/video/view/video_tabbar_menu_widget.dart new file mode 100644 index 0000000..2aa6060 --- /dev/null +++ b/lib/hj_page/video/view/video_tabbar_menu_widget.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/assets_tool/app_colors.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/config/address.dart'; +import 'package:hgdj/extension/extensions.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:hgdj/tools_base/debug_log.dart'; +import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart'; +import 'package:hgdj/tools_base/video_download/download_button.dart'; + +import '../../../alert/video/video_line_menu_alert.dart'; +import '../../../hj_model/splash/domain_source_model.dart'; + +/// 播放页「简介/评论」切换栏:原生 TabBar(下划线指示器跟随 TabBarView 滑动连续移动) + 右侧线路切换/下载 +class VideoTabbarMenuWidget extends StatefulWidget { + final TabController tabCtr; + final VideoModel? model; + final VoidCallback? onSwitchLine; //切换 CDN 线路后重新起播 + + const VideoTabbarMenuWidget( + this.tabCtr, { + this.model, + this.onSwitchLine, + super.key, + }); + + @override + State createState() { + return _VideoTabbarMenuWidgetState(); + } +} + +class _VideoTabbarMenuWidgetState extends State { + TabController get tabCtr => widget.tabCtr; + + VideoModel? get videoModel => widget.model; + + @override + Widget build(BuildContext context) { + Domain? selectedCnd; + try { + selectedCnd = Address.cdnAddressLists + .firstWhere((element) => element.url == Address.cdnAddress); + } catch (e) { + debugLog(e); + } + return SizedBox( + height: 44, + child: Column( + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TabBar( + controller: tabCtr, + isScrollable: true, + tabAlignment: TabAlignment.start, + padding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: 12), + labelColor: const Color(0xE5FFFFFF), + unselectedLabelColor: const Color(0x73FFFFFF), + labelStyle: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w600), + unselectedLabelStyle: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w400), + indicatorSize: TabBarIndicatorSize.label, + dividerHeight: 0, // 去掉 TabBar 默认底部分割线(外层已有 0.5.line) + indicator: CustomIndicator( + color: AppColors.actionRed, + width: 16, + height: 4, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(3)), + ), + tabs: [ + const Tab(text: "简介"), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("评论"), + const SizedBox(width: 2), + Text( + videoModel?.commentCount?.countStr ?? '0', + style: const TextStyle( + fontSize: 12, + height: 1.2, + fontWeight: FontWeight.w400, + color: Color(0xff989898), + ), + ), + ], + ), + ), + ], + ), + ), + // 简介/评论带下划线需贴底,线路切换和下载单独包一层填满高度后垂直居中 + SizedBox( + height: double.infinity, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + /// 线路切换 + InkWell( + enableFeedback: false, + onTap: () async { + if (await VideoLineMenuAlert.show()) + widget.onSwitchLine?.call(); + setState(() {}); + }, + child: Row( + children: [ + Image.asset("line_switch.webp".videoPath, + width: 16), + 2.sizeBoxW, + Text( + selectedCnd?.desc ?? '', + style: const TextStyle( + color: Color(0xff989898), + fontSize: 12, + ), + ), + ], + ), + ), + 12.sizeBoxW, + DownloadButton( + key: ValueKey(videoModel?.id), + video: videoModel, + isShort: false, + ), + ], + ), + ), + 12.sizeBoxW, + ], + ), + ), + 0.5.line, + ], + ), + ); + } +} diff --git a/lib/hj_page/video/view/vip_promo_banner.dart b/lib/hj_page/video/view/vip_promo_banner.dart new file mode 100644 index 0000000..7e10327 --- /dev/null +++ b/lib/hj_page/video/view/vip_promo_banner.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:hgdj/hj_model/video_model.dart'; +import 'package:hgdj/hj_utils/screen.dart'; +import 'package:provider/provider.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../hj_model/splash/domain_source_model.dart'; +import '../../../tools_base/global_store/store.dart'; +import '../../../tools_base/widget/net_image_widget.dart'; +import '../../main_page/provider/msg_provider.dart'; +import '../../mine/mine_vip/pay_order_source.dart'; +import '../../mine/mine_vip/vip_product_manager.dart'; +import '../../pre_sale/limit_time_provider.dart'; + +//播放页横幅:限时活动 / 支付分层横幅 +class VipPromoBanner extends StatefulWidget { + final VideoPlayerController? playCtr; + final VideoModel? videoModel; + final EdgeInsetsGeometry? margin; + final bool isFromHome; + + const VipPromoBanner({ + super.key, + this.playCtr, + this.videoModel, + this.margin, + this.isFromHome = false, + }); + + @override + State createState() { + return _VipPromoBanner(); + } +} + +class _VipPromoBanner extends State { + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (context, newser, limitTime, child) { + // 1. 限时活动优先 + if (limitTime.canShow) return LimitTimeBanner(); + // 2. 支付分层横幅(playPage 图 + vipCard 跳转),都没有则不展示 + final layeredConfig = MineMsgProvider().payTier?.config; + if ((layeredConfig?.playPage ?? '').isNotEmpty) + return _buildLayeredBanner(layeredConfig!); + return const SizedBox(); + }, + ); + } + + /// 支付分层横幅:用后端 playPage 图,点击弹会员弹窗(默认选中 vipCard),倒计时按 lastDiscountTime + Widget _buildLayeredBanner(PayTierConfig config) { + return InkWell( + enableFeedback: false, + onTap: () async { + // 与首页分层弹窗 GuideHomeDialog 一致:暂停播放 → 直接拉起 vipCard 支付方式弹窗 → 刷新用户信息 + widget.playCtr?.pause(); + await vipProductManager.payByVipCard( + config.vipCard, + reportAnalytics: false, // 视频底部分层 banner:只拉支付,不上报 VIP 卡皮事件 + orderTrack: PayOrderTrackInfo( + sourcePage: PaySourcePage.videoBottomBanner, + sourceRef: widget.videoModel?.id, + videoId: widget.videoModel?.id, + ), + ); + await globalStore.updateUserInfo(); + if (mounted) setState(() {}); + }, + child: Container( + height: 46, + width: screen.screenWidth, + margin: widget.margin, + child: Stack( + fit: StackFit.expand, + children: [ + // 分层横幅图(后端加密图) + NetworkImageLoader( + imageUrl: config.playPage ?? "", + fit: BoxFit.fill, + borderRadius: 0), + // 分层倒计时:监听 tick 每秒刷新,过期自动收起 + ValueListenableBuilder( + valueListenable: MineMsgProvider().tick, + builder: (_, __, ___) => config.hasDiscountCountdown + ? _buildLayeredCountdown(config) + : const SizedBox.shrink(), + ), + ], + ), + ), + ); + } + + /// 分层倒计时行(时分秒块叠在 playPage 图上,位置按后端图设计,不对就调 sizeBoxW) + Widget _buildLayeredCountdown(PayTierConfig config) { + return Row( + children: [ + 162.sizeBoxW, + _layeredTimeItem(config.discountHour), + _layeredColon(), + _layeredTimeItem(config.discountMin), + _layeredColon(), + _layeredTimeItem(config.discountSec), + const Spacer(), + ], + ); + } + + Widget _layeredTimeItem(String value) { + return Container( + width: 22, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xff1B1B1B), + borderRadius: BorderRadius.circular(3), + border: Border.all(color: const Color(0xffFFE381), width: 0.5), + ), + child: Text(value, + style: const TextStyle(color: Colors.white, fontSize: 12)), + ); + } + + Widget _layeredColon() => const Padding( + padding: EdgeInsets.symmetric(horizontal: 5), + child: Text(":", style: TextStyle(color: Colors.white, fontSize: 12)), + ); +} diff --git a/lib/hj_page/web_page/h5_logic.dart b/lib/hj_page/web_page/h5_logic.dart new file mode 100644 index 0000000..00bd64d --- /dev/null +++ b/lib/hj_page/web_page/h5_logic.dart @@ -0,0 +1,55 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/hj_page/mine/mine_vip/pay_order_source.dart'; +import 'package:hgdj/routers/jump_router.dart'; + +class H5Logic extends GetxController { + // ========== 属性声明 ========== + final String? url; + + H5Logic({this.url}); + + InAppWebViewController? webViewCtr; + + // url 兜底空字符串,防 null 强解包崩溃 + URLRequest get initialUrlRequest => URLRequest(url: WebUri(url ?? '')); + + // ========== 公开方法 ========== + void onWebViewCreated(InAppWebViewController controller) { + webViewCtr = controller; + _setupHandlers(controller); + } + + // ========== 私有方法 ========== + // 注册 H5 与 native 的 JS 桥 + void _setupHandlers(InAppWebViewController controller) { + controller.addJavaScriptHandler( + handlerName: 'prizeBack', callback: (_) => Get.back()); + controller.addJavaScriptHandler( + handlerName: 'shopBack', callback: (_) => Get.back()); + // 跳金币充值 + controller.addJavaScriptHandler( + handlerName: 'shopCharge', + callback: (_) => pushToWalletPage( + tabPosition: 1, sourcePage: PaySourcePage.h5Activity), + ); + // H5 触发外部链接跳转 + controller.addJavaScriptHandler( + handlerName: 'jumpOutLink', + callback: (args) { + if (args.isNotEmpty && args[0] is String) { + pushToPageByLink(args[0] as String); + } + }, + ); + // H5 触发:直接跳系统外部浏览器(callHandler('openOutLink', url)) + controller.addJavaScriptHandler( + handlerName: 'openOutLink', + callback: (args) { + if (args.isNotEmpty && args[0] is String) { + launchUrlToWeb(args[0] as String); + } + }, + ); + } +} diff --git a/lib/hj_page/web_page/h5_page.dart b/lib/hj_page/web_page/h5_page.dart new file mode 100644 index 0000000..0613b4b --- /dev/null +++ b/lib/hj_page/web_page/h5_page.dart @@ -0,0 +1,76 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:get/get.dart'; +import 'package:hgdj/assets_tool/images.dart'; +import 'package:hgdj/tools_base/unique_tag_mixin.dart'; + +import 'h5_logic.dart'; +import 'h5_webview_settings.dart'; + +class H5Page extends StatefulWidget { + final String? url; + final String? title; + final bool isNeedScaffold; + final bool showTitle; + final bool showClose; + + const H5Page({ + super.key, + this.url, + this.title, + this.isNeedScaffold = true, + this.showTitle = true, + this.showClose = false, + }); + + @override + State createState() => _H5PageState(); +} + +class _H5PageState extends State with UniqueTagMixin { + @override + Widget build(BuildContext context) { + return GetBuilder( + tag: uniqueTag, // H5 可跳 H5、多实例并存,用 uniqueTag 隔离各自 logic + init: H5Logic(url: widget.url), + builder: (logic) { + return Scaffold( + appBar: + widget.showTitle ? AppBar(title: Text(widget.title ?? "")) : null, + body: SafeArea( + child: Stack( + children: [ + _webView(logic), + if (widget.showClose) _closeButton(), + ], + ), + ), + ); + }, + ); + } + + Widget _webView(H5Logic logic) { + return InAppWebView( + initialUrlRequest: logic.initialUrlRequest, + initialSettings: h5WebViewSettings, + initialUserScripts: UnmodifiableListView([]), + onWebViewCreated: logic.onWebViewCreated, + onLoadStart: (_, url) => debugPrint("onPageFinished$url"), + ); + } + + Widget _closeButton() { + return Positioned( + top: 20, + right: 16, + child: InkWell( + enableFeedback: false, + onTap: () => Get.back(), + child: Image.asset('search_close.png'.commonImgPath, width: 30), + ), + ); + } +} diff --git a/lib/hj_page/web_page/h5_webview_settings.dart b/lib/hj_page/web_page/h5_webview_settings.dart new file mode 100644 index 0000000..34f5b89 --- /dev/null +++ b/lib/hj_page/web_page/h5_webview_settings.dart @@ -0,0 +1,16 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +/// 项目内 H5 WebView 的公共配置 +/// +/// iOS 不配这两个媒体开关时,H5 里