add
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
@@ -0,0 +1,33 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
- platform: android
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
- platform: ios
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,16 @@
|
||||
# video_app
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.jsnbhh.video_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.jsnbhh.video_app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="video_app"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.jsnbhh.video_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.9.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,34 @@
|
||||
**/dgph
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.perspectivev3
|
||||
**/*sync/
|
||||
.sconsign.dblite
|
||||
.tags*
|
||||
**/.vagrant/
|
||||
**/DerivedData/
|
||||
Icon?
|
||||
**/Pods/
|
||||
**/.symlinks/
|
||||
profile
|
||||
xcuserdata
|
||||
**/.generated/
|
||||
Flutter/App.framework
|
||||
Flutter/Flutter.framework
|
||||
Flutter/Flutter.podspec
|
||||
Flutter/Generated.xcconfig
|
||||
Flutter/ephemeral/
|
||||
Flutter/app.flx
|
||||
Flutter/app.zip
|
||||
Flutter/flutter_assets/
|
||||
Flutter/flutter_export_environment.sh
|
||||
ServiceDefinitions.json
|
||||
Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.pbxuser
|
||||
!default.perspectivev3
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1,616 @@
|
||||
// !$*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 */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
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 */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
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 = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
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;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.jsnbhh.videoApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C8088294A63A400263BE5 /* Debug */,
|
||||
331C8089294A63A400263BE5 /* Release */,
|
||||
331C808A294A63A400263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147031CF9000F007C117D /* Debug */,
|
||||
97C147041CF9000F007C117D /* Release */,
|
||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
97C147071CF9000F007C117D /* Release */,
|
||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,13 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -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.
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Video App</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>video_app</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<!-- 相册选取:头像上传与视频发布均需读取相册(image_picker 要求) -->
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>用于从相册选择头像或视频素材</string>
|
||||
<!-- 支付渠道跳转:仅允许系统查询以下 App / scheme,其他一律不暴露 -->
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>alipay</string>
|
||||
<string>alipays</string>
|
||||
<string>alipayhk</string>
|
||||
<string>weixin</string>
|
||||
<string>weixinULAPI</string>
|
||||
</array>
|
||||
<!-- 支付宝/微信支付完成后的回跳入口;scheme 须与 pubspec.yaml 中 tobias/fluwx 配置保持一致 -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>alipay</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>videoapp256kb</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>wechat</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>wx0000000000000000</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/// 全局配置常量(域名发现、公共请求头、版本号,对应《01-开发文档》5.5/5.6/5.7)
|
||||
class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
/// App 版本(对应 pubspec version 的 name 部分;version_code 对应 build 号)
|
||||
static const String appVersion = '1.0.0';
|
||||
static const int appVersionCode = 1;
|
||||
|
||||
/// 平台标识(运行时按 iOS/Android 判定,见 platformName)
|
||||
static const String platformNameIOS = 'ios';
|
||||
static const String platformNameAndroid = 'android';
|
||||
|
||||
/// 域名发现:DNS TXT 记录域名与兜底域名后缀
|
||||
static const String dnsTxtHost = 'txt.256kb.top';
|
||||
static const String fallbackDomainSuffix = '_jsnbhh.vip';
|
||||
|
||||
/// DoH 服务(阿里 / 腾讯),TXT 解析走 JSON API
|
||||
static const List<String> dohServers = [
|
||||
'https://223.5.5.5/resolve',
|
||||
'https://119.29.29.29/dns-query',
|
||||
];
|
||||
|
||||
/// 缓存域名连续失败达到该阈值后,重新走完整发现流程
|
||||
static const int domainFailureThreshold = 3;
|
||||
|
||||
/// 开发调试:--dart-define=API_BASE=http://localhost:8000 可直接指定本地服务,
|
||||
/// 跳过域名发现(生产构建不传即走 TXT/兜底逻辑)
|
||||
static const String devApiBase = String.fromEnvironment('API_BASE');
|
||||
|
||||
/// 微信支付 iOS 回跳 Universal Link:
|
||||
/// --dart-define=WECHAT_UNIVERSAL_LINK=https://app.256kb.top/wechat
|
||||
/// 须与 pubspec.yaml 中 fluwx.ios.universal_link 保持一致;未传时 iOS 端无法回跳
|
||||
static const String wechatUniversalLink =
|
||||
String.fromEnvironment('WECHAT_UNIVERSAL_LINK');
|
||||
|
||||
/// 请求超时
|
||||
static const Duration connectTimeout = Duration(seconds: 10);
|
||||
static const Duration receiveTimeout = Duration(seconds: 20);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import '../../models/video_item.dart';
|
||||
|
||||
/// 开发期播放地址钩子:
|
||||
/// 服务端 /hls 转码产物未就绪前,用 --dart-define=TEST_VIDEO_URL=xxx 注入公网
|
||||
/// 测试 m3u8,把 video_url 为空或相对路径的条目替换为测试流以验证播放器。
|
||||
/// 生产构建不传该 define,零开销;服务端 /hls 就绪后此钩子可保留作降级兜底。
|
||||
const String _testStreamUrl = String.fromEnvironment('TEST_VIDEO_URL');
|
||||
|
||||
/// 解析实际播放地址。
|
||||
/// - 注入了测试流且原始地址不可用(空/非 http 绝对地址)→ 返回测试流
|
||||
/// - 否则返回原始 video_url
|
||||
///
|
||||
/// [testUrl] 可选参数便于单元测试(fromEnvironment 在测试中恒为空)。
|
||||
String resolvePlayUrl(VideoItem item, {String? testUrl}) {
|
||||
final injected = testUrl ?? _testStreamUrl;
|
||||
final raw = item.videoUrl.trim();
|
||||
final playable = raw.startsWith('http://') || raw.startsWith('https://');
|
||||
if (!playable && injected.isNotEmpty) return injected;
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'api_domain.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'endpoints.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/local_store.dart';
|
||||
import '../storage/token_store.dart';
|
||||
|
||||
/// 统一网络客户端:
|
||||
/// - Base URL 来自域名发现(ApiDomain),不硬编码任何 API 域名
|
||||
/// - 公共请求头:X-Device-ID / X-App-Version / X-Platform / X-Request-ID
|
||||
/// - 登录态:Authorization: Bearer `access_token`(TokenStore 内存缓存)
|
||||
/// - 统一响应解包:code=0 → data;非 0 → ApiException;HTTP 401 → 静默刷新重试一次或登出
|
||||
/// - 缓存域名连续失败达阈值 → 重新域名发现并切换 Base URL
|
||||
class ApiClient {
|
||||
ApiClient._();
|
||||
|
||||
static late Dio _dio;
|
||||
static Dio get I => _dio;
|
||||
|
||||
/// 401 且无法刷新时的回调(由 AuthProvider 注册:清登录态并跳登录页)
|
||||
static void Function()? onSessionExpired;
|
||||
|
||||
/// 初始化(splash 阶段调用一次)
|
||||
static Future<void> init() async {
|
||||
final base = await ApiDomain.resolve();
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: base,
|
||||
connectTimeout: AppConfig.connectTimeout,
|
||||
receiveTimeout: AppConfig.receiveTimeout,
|
||||
// 业务错误统一 HTTP 200 + code,服务端 4xx/5xx 也走统一处理
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
));
|
||||
_dio.interceptors.addAll([
|
||||
_CommonHeadersInterceptor(),
|
||||
_UnwrapInterceptor(),
|
||||
_ErrorInterceptor(),
|
||||
]);
|
||||
}
|
||||
|
||||
/// ---------- 便捷请求方法:直接返回解包后的 data ----------
|
||||
/// 拦截器已完成 code!=0 → 异常、401 → 刷新重试 的统一处理
|
||||
|
||||
static Future<T> get<T>(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
final resp = await _dio.get<dynamic>(path, queryParameters: queryParameters);
|
||||
return resp.data as T;
|
||||
}
|
||||
|
||||
static Future<T> post<T>(String path,
|
||||
{Object? data, ProgressCallback? onSendProgress}) async {
|
||||
final resp = await _dio.post<dynamic>(path,
|
||||
data: data, onSendProgress: onSendProgress);
|
||||
return resp.data as T;
|
||||
}
|
||||
|
||||
static Future<T> put<T>(String path, {Object? data}) async {
|
||||
final resp = await _dio.put<dynamic>(path, data: data);
|
||||
return resp.data as T;
|
||||
}
|
||||
|
||||
static Future<T> delete<T>(String path, {Object? data}) async {
|
||||
final resp = await _dio.delete<dynamic>(path, data: data);
|
||||
return resp.data as T;
|
||||
}
|
||||
}
|
||||
|
||||
/// 公共请求头注入
|
||||
class _CommonHeadersInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
options.headers['X-Device-ID'] = LocalStore.I.deviceId;
|
||||
options.headers['X-App-Version'] = AppConfig.appVersion;
|
||||
options.headers['X-Platform'] = Platform.isIOS
|
||||
? AppConfig.platformNameIOS
|
||||
: AppConfig.platformNameAndroid;
|
||||
options.headers['X-Request-ID'] = const Uuid().v4();
|
||||
final token = TokenStore.accessToken;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一响应解包:{ code, message, data } → code=0 返回 data,非 0 抛 ApiException
|
||||
class _UnwrapInterceptor extends Interceptor {
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
ApiDomain.markSuccess();
|
||||
final body = response.data;
|
||||
if (body is Map && body.containsKey('code')) {
|
||||
final code = body['code'] as int;
|
||||
if (code == 0) {
|
||||
response.data = body['data'];
|
||||
return handler.next(response);
|
||||
}
|
||||
return handler.reject(DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
error: ApiException(
|
||||
code,
|
||||
(body['message'] as String?) ?? '请求失败',
|
||||
data: body['data'] is Map<String, dynamic>
|
||||
? body['data'] as Map<String, dynamic>
|
||||
: null,
|
||||
),
|
||||
));
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误统一转 ApiException;401 尝试静默刷新并重试一次,失败则回调登出
|
||||
class _ErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// 网络层失败(域名不可达等):计数并按需重新发现域名
|
||||
if (err.type == DioExceptionType.connectionError ||
|
||||
err.type == DioExceptionType.connectionTimeout) {
|
||||
await ApiDomain.rediscoverIfBroken();
|
||||
ApiClient._dio.options.baseUrl = ApiDomain.current;
|
||||
}
|
||||
|
||||
final apiError = err.error;
|
||||
final status = err.response?.statusCode;
|
||||
|
||||
// 401:静默刷新重试一次
|
||||
if (status == 401 &&
|
||||
err.requestOptions.extra['retried'] != true &&
|
||||
TokenStore.refreshToken != null) {
|
||||
final newToken = await _refreshToken();
|
||||
if (newToken != null) {
|
||||
try {
|
||||
final opts = err.requestOptions;
|
||||
opts.extra['retried'] = true;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final resp = await ApiClient._dio.fetch<dynamic>(opts);
|
||||
return handler.resolve(resp);
|
||||
} catch (_) {
|
||||
// 重试仍失败 → 走下方登出
|
||||
}
|
||||
}
|
||||
await TokenStore.clear();
|
||||
ApiClient.onSessionExpired?.call();
|
||||
}
|
||||
|
||||
final message = apiError is ApiException ? apiError.message : '网络异常,请稍后重试';
|
||||
final code = apiError is ApiException ? apiError.code : -1;
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ApiException(code, message),
|
||||
));
|
||||
}
|
||||
|
||||
/// 刷新令牌(旋转:旧 refresh 立即失效);用独立 Dio 避免拦截器递归
|
||||
Future<String?> _refreshToken() async {
|
||||
final refresh = TokenStore.refreshToken;
|
||||
if (refresh == null) return null;
|
||||
try {
|
||||
final bare = Dio(BaseOptions(
|
||||
baseUrl: ApiDomain.current,
|
||||
validateStatus: (s) => s != null && s < 500,
|
||||
));
|
||||
final resp = await bare.post<Map<String, dynamic>>(
|
||||
Endpoints.tokenRefresh,
|
||||
data: {'refresh_token': refresh},
|
||||
);
|
||||
final body = resp.data;
|
||||
if (body != null && body['code'] == 0) {
|
||||
final data = body['data'] as Map<String, dynamic>;
|
||||
await TokenStore.save(
|
||||
access: data['access_token'] as String,
|
||||
refresh: data['refresh_token'] as String,
|
||||
);
|
||||
return TokenStore.accessToken;
|
||||
}
|
||||
} catch (_) {
|
||||
// 刷新失败视为会话过期
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/local_store.dart';
|
||||
|
||||
/// API 域名发现(《01-开发文档》5.5):
|
||||
/// 1. 优先使用 --dart-define=API_BASE 调试覆盖
|
||||
/// 2. 本地缓存域名
|
||||
/// 3. DoH 解析 txt.256kb.top 的 TXT 记录,内容即真实 API 域名
|
||||
/// 4. 兜底:md5(yyyyMM) + "_jsnbhh.vip"
|
||||
/// 结果统一拼 https:// 作为 Base URL 并写入缓存。
|
||||
class ApiDomain {
|
||||
ApiDomain._();
|
||||
|
||||
static final Dio _doh = Dio(BaseOptions(
|
||||
connectTimeout: AppConfig.connectTimeout,
|
||||
receiveTimeout: AppConfig.connectTimeout,
|
||||
));
|
||||
|
||||
static int _failCount = 0;
|
||||
|
||||
/// 当前生效的 Base URL(含 https:// 前缀,无尾斜杠)
|
||||
static String? _current;
|
||||
static String get current => _current ?? '';
|
||||
|
||||
/// 启动时解析域名:按优先级逐级回退
|
||||
static Future<String> resolve() async {
|
||||
// 0. 调试覆盖
|
||||
if (AppConfig.devApiBase.isNotEmpty) {
|
||||
_current = _normalize(AppConfig.devApiBase);
|
||||
return _current!;
|
||||
}
|
||||
// 1. 本地缓存
|
||||
final cached = LocalStore.I.cachedBaseUrl;
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_current = cached;
|
||||
return cached;
|
||||
}
|
||||
// 2/3. TXT → 兜底域名
|
||||
_current = await _discover();
|
||||
return _current!;
|
||||
}
|
||||
|
||||
/// 缓存域名连续失败达到阈值时重新发现(由 ApiClient 调用)
|
||||
static Future<String> rediscoverIfBroken() async {
|
||||
_failCount++;
|
||||
if (_failCount < AppConfig.domainFailureThreshold) return current;
|
||||
_failCount = 0;
|
||||
final next = await _discover();
|
||||
if (next != current) {
|
||||
_current = next;
|
||||
await LocalStore.I.saveBaseUrl(next);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/// 请求成功时清零失败计数
|
||||
static void markSuccess() => _failCount = 0;
|
||||
|
||||
/// 完整发现流程:TXT → 兜底
|
||||
static Future<String> _discover() async {
|
||||
final host = await _resolveTxt();
|
||||
if (host != null && host.isNotEmpty) {
|
||||
final base = _normalize(host);
|
||||
await LocalStore.I.saveBaseUrl(base);
|
||||
return base;
|
||||
}
|
||||
final base = _normalize(_fallbackDomain());
|
||||
await LocalStore.I.saveBaseUrl(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
/// DoH 解析 TXT:优先阿里 223.5.5.5,失败切腾讯 119.29.29.29
|
||||
static Future<String?> _resolveTxt() async {
|
||||
for (final server in AppConfig.dohServers) {
|
||||
try {
|
||||
final resp = await _doh.get<dynamic>(
|
||||
server,
|
||||
queryParameters: {'name': AppConfig.dnsTxtHost, 'type': 'TXT'},
|
||||
);
|
||||
final body = resp.data;
|
||||
final answers = body is Map ? body['Answer'] as List? : null;
|
||||
if (answers == null || answers.isEmpty) continue;
|
||||
// TXT 记录值形如 "\"api.xxx.com\"",去掉引号
|
||||
final raw = (answers.first['data'] as String? ?? '').replaceAll('"', '').trim();
|
||||
if (raw.isNotEmpty) return raw;
|
||||
} catch (_) {
|
||||
continue; // 单个 DoH 失败不影响整体回退
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 兜底域名:md5(yyyyMM)(32 位小写)+ 后缀
|
||||
static String _fallbackDomain() {
|
||||
final now = DateTime.now();
|
||||
final ym = '${now.year}${now.month.toString().padLeft(2, '0')}';
|
||||
final digest = md5.convert(utf8.encode(ym)).toString();
|
||||
return '$digest${AppConfig.fallbackDomainSuffix}';
|
||||
}
|
||||
|
||||
/// 归一化为 Base URL:补 https://,去尾斜杠
|
||||
static String _normalize(String hostOrUrl) {
|
||||
var s = hostOrUrl.trim();
|
||||
if (!s.startsWith('http://') && !s.startsWith('https://')) {
|
||||
s = 'https://$s';
|
||||
}
|
||||
return s.endsWith('/') ? s.substring(0, s.length - 1) : s;
|
||||
}
|
||||
|
||||
/// 统一业务错误转 ApiException(域名探测等场景复用)
|
||||
static ApiException wrap(Object error) {
|
||||
if (error is ApiException) return error;
|
||||
return ApiException(-1, '网络异常,请稍后重试');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// 业务异常:对应统一响应结构 { code, message, data }
|
||||
/// code=0 为成功;非 0 抛出该异常由 UI 层捕获提示(错误码表见《03-接口文档》1.3)
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.code, this.message, {this.data});
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
/// 错误响应携带的 data(如 2001 积分不足时含当前 points、2002 含 need_login)
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
/// 未登录或 Token 无效/过期
|
||||
bool get isUnauthorized => code == 1002;
|
||||
|
||||
/// 游客今日免费次数已用完(需登录引导)
|
||||
bool get isGuestLimit => code == 2002;
|
||||
|
||||
/// 积分不足(需引导充值/开通会员)
|
||||
bool get isPointsInsufficient => code == 2001;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code, $message)';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/// 接口路径常量(《03-接口文档》第 2~10 章)
|
||||
/// 统一响应已由 ApiClient 解包,各 Service 只关心 data。
|
||||
class Endpoints {
|
||||
Endpoints._();
|
||||
|
||||
// ---------- 认证 ----------
|
||||
static const smsSend = '/auth/sms/send';
|
||||
static const emailSend = '/auth/email/send';
|
||||
static const register = '/auth/register';
|
||||
static const login = '/auth/login';
|
||||
static const tokenRefresh = '/auth/token/refresh';
|
||||
static const logout = '/auth/logout';
|
||||
static const passwordReset = '/auth/password/reset';
|
||||
|
||||
// ---------- 用户 ----------
|
||||
static const me = '/users/me';
|
||||
static const meAvatar = '/users/me/avatar';
|
||||
static const meWallet = '/users/me/wallet';
|
||||
static const mePointsRecords = '/users/me/points/records';
|
||||
static const meDeactivate = '/users/me/deactivate';
|
||||
static String userDetail(int userId) => '/users/$userId';
|
||||
static String userVideos(int userId) => '/users/$userId/videos';
|
||||
static const meVideos = '/users/me/videos';
|
||||
static const meFavorites = '/users/me/favorites';
|
||||
static const meHistory = '/users/me/history';
|
||||
|
||||
// ---------- 视频 ----------
|
||||
static const feed = '/videos/feed';
|
||||
static const categories = '/videos/categories';
|
||||
static const uploadToken = '/videos/upload/token';
|
||||
static const uploadFile = '/videos/upload/file';
|
||||
static const publish = '/videos';
|
||||
static const search = '/videos/search';
|
||||
static const hotSearch = '/search/hot';
|
||||
static String videoDetail(int videoId) => '/videos/$videoId';
|
||||
static String videoWatch(int videoId) => '/videos/$videoId/watch';
|
||||
static String videoWatchComplete(int videoId) => '/videos/$videoId/watch/complete';
|
||||
static String videoLike(int videoId) => '/videos/$videoId/like';
|
||||
static String videoFavorite(int videoId) => '/videos/$videoId/favorite';
|
||||
static String videoComments(int videoId) => '/videos/$videoId/comments';
|
||||
static String videoEdit(int videoId) => '/videos/$videoId';
|
||||
static String videoDelete(int videoId) => '/videos/$videoId';
|
||||
static String tagVideos(String tagName) => '/tags/$tagName/videos';
|
||||
|
||||
// ---------- 互动 ----------
|
||||
static String commentDelete(int commentId) => '/comments/$commentId';
|
||||
|
||||
// ---------- 会员 ----------
|
||||
static const membershipPlans = '/membership/plans';
|
||||
static const membershipMe = '/membership/me';
|
||||
static const membershipRedeem = '/membership/redeem';
|
||||
|
||||
// ---------- 支付 ----------
|
||||
static const payPackages = '/payments/packages';
|
||||
static const recharge = '/payments/recharge';
|
||||
static String orderDetail(String orderNo) => '/payments/orders/$orderNo';
|
||||
static String orderCancel(String orderNo) => '/payments/orders/$orderNo/cancel';
|
||||
|
||||
// ---------- 激励 / 推广归因 ----------
|
||||
static const referralMe = '/referral/me';
|
||||
static const referralBind = '/referral/bind';
|
||||
static const inviteMatch = '/invite/match';
|
||||
static const assetsMe = '/assets/me';
|
||||
static const assetsRecords = '/assets/records';
|
||||
static const assetsWithdraw = '/assets/withdraw';
|
||||
static const withdrawRecords = '/assets/withdraw/records';
|
||||
|
||||
// ---------- 通用 ----------
|
||||
static const appVersion = '/app/version';
|
||||
static const notifications = '/notifications';
|
||||
static const notificationsUnread = '/notifications/unread_count';
|
||||
static const notificationsRead = '/notifications/read';
|
||||
static const report = '/reports';
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
|
||||
class PlayerSlot {
|
||||
PlayerSlot(this.player);
|
||||
|
||||
final Player player;
|
||||
late final VideoController controller = VideoController(player);
|
||||
|
||||
/// 当前槽位绑定的 videoId(-1 表示空槽)
|
||||
int videoId = -1;
|
||||
|
||||
/// 是否已回调过 complete(每视频每次绑定只触发一次)
|
||||
bool completeNotified = false;
|
||||
}
|
||||
|
||||
/// Feed 播放器池:3 槽位循环复用,防止 PageView 无限列表下 OOM。
|
||||
///
|
||||
/// 策略:
|
||||
/// - 窗口 [current-1, current, current+1] 映射到 index % 3 槽位
|
||||
/// - 当前槽 play,相邻槽仅 open 预缓冲,出窗槽 stop 复位
|
||||
/// - 每个槽位独立订阅进度流:position ≥ duration×95% 或 completed → 回调一次 complete
|
||||
///
|
||||
/// 构造接受 [playerFactory] 便于测试注入 fake(本机无播放器环境)。
|
||||
class FeedPlayerPool {
|
||||
FeedPlayerPool({
|
||||
Player Function()? playerFactory,
|
||||
this.onWatchComplete,
|
||||
}) : _playerFactory = playerFactory;
|
||||
|
||||
final Player Function()? _playerFactory;
|
||||
|
||||
/// 完整观看回调:(videoId, watchDuration秒),由上层做去重与上报
|
||||
final void Function(int videoId, int watchDurationSeconds)? onWatchComplete;
|
||||
|
||||
static const int slotCount = 3;
|
||||
|
||||
final List<PlayerSlot> _slots = [];
|
||||
final List<StreamSubscription> _subs = [];
|
||||
|
||||
int _currentIndex = 0;
|
||||
int get currentIndex => _currentIndex;
|
||||
|
||||
/// 初始化(创建 3 槽位并订阅各自进度流)
|
||||
void init() {
|
||||
for (var i = 0; i < slotCount; i++) {
|
||||
final player = _playerFactory?.call() ?? Player();
|
||||
final slot = PlayerSlot(player);
|
||||
_slots.add(slot);
|
||||
_hookSlot(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// 槽位号(PageView 页用 index % 3 取对应 controller 渲染)
|
||||
int slotOf(int index) => index % slotCount;
|
||||
|
||||
PlayerSlot slotAt(int slotIndex) => _slots[slotIndex];
|
||||
VideoController controllerAt(int slotIndex) => _slots[slotIndex].controller;
|
||||
|
||||
PlayerSlot get currentSlot => _slots[slotOf(_currentIndex)];
|
||||
|
||||
bool get isCurrentPlaying => currentSlot.player.state.playing;
|
||||
|
||||
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
||||
void bindWindow(int index, List<({int videoId, String url})> items) {
|
||||
_currentIndex = index;
|
||||
// 1. 窗口内槽位按需加载
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i < 0 || i >= items.length) continue;
|
||||
final slot = _slots[slotOf(i)];
|
||||
final item = items[i];
|
||||
if (slot.videoId == item.videoId) continue; // 已加载,避免重复 open
|
||||
slot.videoId = item.videoId;
|
||||
slot.completeNotified = false;
|
||||
if (item.url.isNotEmpty) {
|
||||
slot.player.open(Media(item.url), play: false);
|
||||
}
|
||||
}
|
||||
// 2. 槽位状态:当前播、相邻预缓冲(保持 pause)、出窗复位
|
||||
for (var s = 0; s < slotCount; s++) {
|
||||
final slot = _slots[s];
|
||||
if (s == slotOf(index)) continue;
|
||||
if (slot.videoId == -1) continue;
|
||||
if (_inWindow(slot.videoId, index, items)) {
|
||||
slot.player.pause(); // 窗口内非当前:预缓冲不播放
|
||||
} else {
|
||||
slot.player.stop();
|
||||
slot.videoId = -1; // 出窗复位
|
||||
}
|
||||
}
|
||||
currentSlot.player.play();
|
||||
}
|
||||
|
||||
bool _inWindow(int videoId, int index, List<({int videoId, String url})> items) {
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i >= 0 && i < items.length && items[i].videoId == videoId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
|
||||
void _hookSlot(PlayerSlot slot) {
|
||||
var lastPosition = Duration.zero;
|
||||
_subs.add(slot.player.stream.position.listen((pos) {
|
||||
lastPosition = pos;
|
||||
if (slot.videoId < 0 || slot.completeNotified) return;
|
||||
// 仅当前槽才上报(预缓冲槽不应触发)
|
||||
if (slot != currentSlot) return;
|
||||
final dur = slot.player.state.duration;
|
||||
if (dur.inMilliseconds > 0 &&
|
||||
pos.inMilliseconds >= dur.inMilliseconds * 0.95) {
|
||||
slot.completeNotified = true;
|
||||
onWatchComplete?.call(slot.videoId, pos.inSeconds);
|
||||
}
|
||||
}));
|
||||
_subs.add(slot.player.stream.completed.listen((done) {
|
||||
if (!done) return;
|
||||
if (slot.videoId < 0 || slot.completeNotified) return;
|
||||
if (slot != currentSlot) return;
|
||||
slot.completeNotified = true;
|
||||
onWatchComplete?.call(slot.videoId, lastPosition.inSeconds);
|
||||
}));
|
||||
}
|
||||
|
||||
/// 暂停/继续/切换当前槽(手势与生命周期用)
|
||||
void pauseCurrent() => currentSlot.player.pause();
|
||||
void playCurrent() => currentSlot.player.play();
|
||||
void toggleCurrent() => currentSlot.player.playOrPause();
|
||||
|
||||
/// 释放全部资源(Feed 页 dispose 时调用)
|
||||
Future<void> dispose() async {
|
||||
for (final sub in _subs) {
|
||||
await sub.cancel();
|
||||
}
|
||||
_subs.clear();
|
||||
for (final slot in _slots) {
|
||||
await slot.player.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 本地轻量配置存储(SharedPreferences)
|
||||
/// device_id 为游客计费与风控的设备指纹:
|
||||
/// 生产要求 Android 存 Keystore 加密的 SharedPreferences、iOS 存 Keychain(卸载重装保留),
|
||||
/// 骨架阶段先持久化到 SharedPreferences,接入原生通道时替换此处实现即可。
|
||||
class LocalStore {
|
||||
LocalStore._(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const String _keyDeviceId = 'device_id';
|
||||
static const String _keyCachedDomain = 'cached_domain';
|
||||
static const String _keyPendingInviteCode = 'pending_invite_code';
|
||||
static const String _keyInviteMatchReported = 'invite_match_reported';
|
||||
|
||||
static LocalStore? _instance;
|
||||
|
||||
/// 初始化(main 中 await 完成后再 runApp)
|
||||
static Future<LocalStore> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_instance = LocalStore._(prefs);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static LocalStore get I => _instance!;
|
||||
|
||||
/// 读取设备 ID;不存在时生成 UUID v4 并持久化
|
||||
String ensureDeviceId(String Function() uuidV4) {
|
||||
final existing = _prefs.getString(_keyDeviceId);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final id = uuidV4();
|
||||
_prefs.setString(_keyDeviceId, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
String get deviceId => _prefs.getString(_keyDeviceId) ?? '';
|
||||
|
||||
/// 待绑定邀请码(首启口令解析/模糊归因命中后写入,注册成功或手填后清除)
|
||||
String? get pendingInviteCode => _prefs.getString(_keyPendingInviteCode);
|
||||
|
||||
Future<void> savePendingInviteCode(String code) =>
|
||||
_prefs.setString(_keyPendingInviteCode, code);
|
||||
|
||||
Future<void> clearPendingInviteCode() => _prefs.remove(_keyPendingInviteCode);
|
||||
|
||||
/// 模糊归因是否已上报过(每安装仅一次,避免重复请求)
|
||||
bool get inviteMatchReported => _prefs.getBool(_keyInviteMatchReported) ?? false;
|
||||
|
||||
Future<void> markInviteMatchReported() =>
|
||||
_prefs.setBool(_keyInviteMatchReported, true);
|
||||
|
||||
/// API 域名缓存(含 https:// 前缀的 Base URL)
|
||||
String? get cachedBaseUrl => _prefs.getString(_keyCachedDomain);
|
||||
|
||||
Future<void> saveBaseUrl(String baseUrl) =>
|
||||
_prefs.setString(_keyCachedDomain, baseUrl);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 搜索历史本地存储(《01-开发文档》2.5.8:App 端本地存储最近 10 条,不上报服务端)
|
||||
/// 最新搜索排最前,重复关键词去重置顶,超出 10 条截断。
|
||||
class SearchHistoryStore {
|
||||
SearchHistoryStore._(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const String _key = 'search_history';
|
||||
static const int maxCount = 10;
|
||||
|
||||
static SearchHistoryStore? _instance;
|
||||
|
||||
/// 初始化(main 阶段调用)
|
||||
static Future<SearchHistoryStore> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_instance = SearchHistoryStore._(prefs);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 测试注入
|
||||
static void initForTest(SharedPreferences prefs) {
|
||||
_instance = SearchHistoryStore._(prefs);
|
||||
}
|
||||
|
||||
static SearchHistoryStore get I => _instance!;
|
||||
|
||||
/// 历史列表(最新在前)
|
||||
List<String> get history => _prefs.getStringList(_key) ?? const [];
|
||||
|
||||
/// 记录一次搜索:去重置顶,保留最近 [maxCount] 条
|
||||
Future<void> add(String keyword) async {
|
||||
final word = keyword.trim();
|
||||
if (word.isEmpty) return;
|
||||
final list = [...history]..remove(word);
|
||||
list.insert(0, word);
|
||||
if (list.length > maxCount) list.length = maxCount;
|
||||
await _prefs.setStringList(_key, list);
|
||||
}
|
||||
|
||||
/// 删除单条
|
||||
Future<void> remove(String keyword) async {
|
||||
final list = [...history]..remove(keyword);
|
||||
await _prefs.setStringList(_key, list);
|
||||
}
|
||||
|
||||
/// 清空
|
||||
Future<void> clear() => _prefs.remove(_key);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Token 安全存储(02-技术文档:flutter_secure_storage)
|
||||
/// 仅存 access / refresh,业务代码不落明文日志。
|
||||
class TokenStore {
|
||||
TokenStore._();
|
||||
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
static const String _keyAccess = 'access_token';
|
||||
static const String _keyRefresh = 'refresh_token';
|
||||
|
||||
/// 内存缓存:Dio 拦截器同步读取,避免每请求异步取 IO
|
||||
static String? accessToken;
|
||||
static String? refreshToken;
|
||||
|
||||
static Future<void> save({
|
||||
required String access,
|
||||
required String refresh,
|
||||
}) async {
|
||||
accessToken = access;
|
||||
refreshToken = refresh;
|
||||
await _storage.write(key: _keyAccess, value: access);
|
||||
await _storage.write(key: _keyRefresh, value: refresh);
|
||||
}
|
||||
|
||||
/// 启动时恢复登录态
|
||||
static Future<void> restore() async {
|
||||
accessToken = await _storage.read(key: _keyAccess);
|
||||
refreshToken = await _storage.read(key: _keyRefresh);
|
||||
}
|
||||
|
||||
/// 登出 / Token 失效:清空内存与本地
|
||||
static Future<void> clear() async {
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
await _storage.delete(key: _keyAccess);
|
||||
await _storage.delete(key: _keyRefresh);
|
||||
}
|
||||
|
||||
static bool get hasToken => accessToken != null && accessToken!.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 观看上报去重存储(《03-接口文档》5.3:客户端本地按 video_id+日期 去重,
|
||||
/// 同日同视频只上报一次)。游客免费额度按设备计费,杀进程重进若重复上报会
|
||||
/// 白扣游客当日次数,因此必须持久化而非仅内存。
|
||||
///
|
||||
/// key 按日分组:`watch_yyyyMMdd` / `complete_yyyyMMdd`,值为当日已上报的
|
||||
/// video_id 列表;每次访问时惰性清理非今日 key。
|
||||
class WatchDedupStore {
|
||||
WatchDedupStore._(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const String _watchPrefix = 'watch_';
|
||||
static const String _completePrefix = 'complete_';
|
||||
|
||||
static WatchDedupStore? _instance;
|
||||
|
||||
/// 初始化(splash 阶段调用)
|
||||
static Future<WatchDedupStore> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_instance = WatchDedupStore._(prefs);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 测试注入
|
||||
static void initForTest(SharedPreferences prefs) {
|
||||
_instance = WatchDedupStore._(prefs);
|
||||
}
|
||||
|
||||
static WatchDedupStore get I => _instance!;
|
||||
|
||||
String get _today {
|
||||
final now = DateTime.now();
|
||||
return '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 是否已上报过 watch
|
||||
bool hasWatched(int videoId) => _contains(_watchPrefix, videoId);
|
||||
|
||||
/// 标记已上报 watch
|
||||
Future<void> markWatched(int videoId) => _mark(_watchPrefix, videoId);
|
||||
|
||||
/// 是否已上报过 complete
|
||||
bool hasCompleted(int videoId) => _contains(_completePrefix, videoId);
|
||||
|
||||
/// 标记已上报 complete
|
||||
Future<void> markCompleted(int videoId) => _mark(_completePrefix, videoId);
|
||||
|
||||
bool _contains(String prefix, int videoId) {
|
||||
_cleanup(prefix);
|
||||
final list = _prefs.getStringList('$prefix$_today') ?? const [];
|
||||
return list.contains(videoId.toString());
|
||||
}
|
||||
|
||||
Future<void> _mark(String prefix, int videoId) async {
|
||||
_cleanup(prefix);
|
||||
final key = '$prefix$_today';
|
||||
final list = _prefs.getStringList(key) ?? <String>[];
|
||||
final id = videoId.toString();
|
||||
if (!list.contains(id)) {
|
||||
list.add(id);
|
||||
await _prefs.setStringList(key, list);
|
||||
}
|
||||
}
|
||||
|
||||
/// 惰性清理:删除该前缀下所有非今日的 key
|
||||
void _cleanup(String prefix) {
|
||||
final todayKey = '$prefix$_today';
|
||||
final stale = _prefs
|
||||
.getKeys()
|
||||
.where((k) => k.startsWith(prefix) && k != todayKey)
|
||||
.toList();
|
||||
for (final k in stale) {
|
||||
_prefs.remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// UI 样式规范(《01-开发文档》5.4):深色沉浸 + 品牌红/青 + 会员金
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
static const Color background = Color(0xFF121212);
|
||||
static const Color surface = Color(0xFF1E1E1E);
|
||||
static const Color primary = Color(0xFFFE2C55); // 品牌红
|
||||
static const Color secondary = Color(0xFF25F4EE); // 辅助青
|
||||
static const Color textPrimary = Color(0xFFFFFFFF);
|
||||
static const Color textSecondary = Color(0xFFB0B0B0);
|
||||
static const Color textWeak = Color(0xFF6E6E6E);
|
||||
static const Color gold = Color(0xFFF7D794);
|
||||
static const Color goldDeep = Color(0xFFD4A017);
|
||||
|
||||
/// 会员金色渐变
|
||||
static const LinearGradient goldGradient = LinearGradient(
|
||||
colors: [gold, goldDeep],
|
||||
);
|
||||
}
|
||||
|
||||
/// 全局深色主题
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static ThemeData get dark => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: AppColors.primary,
|
||||
secondary: AppColors.secondary,
|
||||
surface: AppColors.surface,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: AppColors.background,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
hintStyle: const TextStyle(color: AppColors.textWeak),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
dividerColor: AppColors.textWeak.withValues(alpha: 0.2),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../../models/video_item.dart';
|
||||
import '../config/test_stream.dart';
|
||||
|
||||
/// 分享文案:「标题」#标签 #标签 播放链接(链接不可用时省略)
|
||||
/// 一期走系统分享面板;分享落地页/海报属二期规划(《01-开发文档》2.10)
|
||||
String buildShareText(VideoItem item, {String? testUrl}) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.write(item.title.isNotEmpty ? '「${item.title}」' : '精彩视频');
|
||||
for (final tag in item.tags) {
|
||||
buffer.write(' #$tag');
|
||||
}
|
||||
final url = resolvePlayUrl(item, testUrl: testUrl);
|
||||
// 仅 http(s) 绝对链接可分享(相对路径对外不可达)
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
buffer.write('\n$url');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'core/storage/local_store.dart';
|
||||
import 'core/storage/search_history_store.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'router.dart';
|
||||
|
||||
/// App 入口:初始化本地存储 → MaterialApp.router
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 本地存储初始化(设备指纹在 splash 阶段生成)
|
||||
await LocalStore.init();
|
||||
// 搜索历史(本地,不上报服务端)
|
||||
await SearchHistoryStore.init();
|
||||
|
||||
runApp(const ProviderScope(child: VideoApp()));
|
||||
}
|
||||
|
||||
class VideoApp extends ConsumerWidget {
|
||||
const VideoApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
return MaterialApp.router(
|
||||
title: '短视频',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.dark,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/// 评论模型(《03-接口文档》6.3/6.4/6.5,对应服务端 interaction_service 输出)
|
||||
class CommentItem {
|
||||
CommentItem({
|
||||
required this.commentId,
|
||||
required this.userId,
|
||||
this.nickname = '',
|
||||
this.avatarUrl = '',
|
||||
this.content = '',
|
||||
this.likeCount = 0,
|
||||
this.replyCount = 0,
|
||||
this.createdAt = '',
|
||||
});
|
||||
|
||||
final int commentId;
|
||||
final int userId;
|
||||
final String nickname;
|
||||
final String avatarUrl;
|
||||
final String content;
|
||||
final int likeCount;
|
||||
|
||||
/// 一级评论的回复数(二级回复列表项恒为 0)
|
||||
final int replyCount;
|
||||
final String createdAt;
|
||||
|
||||
CommentItem copyWith({int? replyCount}) => CommentItem(
|
||||
commentId: commentId,
|
||||
userId: userId,
|
||||
nickname: nickname,
|
||||
avatarUrl: avatarUrl,
|
||||
content: content,
|
||||
likeCount: likeCount,
|
||||
replyCount: replyCount ?? this.replyCount,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
factory CommentItem.fromJson(Map<String, dynamic> json) {
|
||||
final user = (json['user'] as Map<String, dynamic>?) ?? const {};
|
||||
return CommentItem(
|
||||
commentId: (json['comment_id'] as num?)?.toInt() ?? 0,
|
||||
userId: (user['user_id'] as num?)?.toInt() ?? 0,
|
||||
nickname: (user['nickname'] as String?) ?? '',
|
||||
avatarUrl: (user['avatar_url'] as String?) ?? '',
|
||||
content: (json['content'] as String?) ?? '',
|
||||
likeCount: (json['like_count'] as num?)?.toInt() ?? 0,
|
||||
replyCount: (json['reply_count'] as num?)?.toInt() ?? 0,
|
||||
createdAt: (json['created_at'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论分页(普通 page/page_size 分页,《03-接口文档》1.4)
|
||||
class CommentListPage {
|
||||
CommentListPage({required this.total, required this.items});
|
||||
|
||||
final int total;
|
||||
final List<CommentItem> items;
|
||||
|
||||
factory CommentListPage.fromJson(Map<String, dynamic> json) =>
|
||||
CommentListPage(
|
||||
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||
items: ((json['list'] as List?) ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(CommentItem.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 评论积分奖励进度(发表/删除评论响应中的 reward_progress)
|
||||
class RewardProgress {
|
||||
RewardProgress({
|
||||
required this.current,
|
||||
required this.target,
|
||||
this.rewardedThisTime = false,
|
||||
this.rewardPoints = 0,
|
||||
});
|
||||
|
||||
/// 当前进度(满 target 自动发奖并清零)
|
||||
final int current;
|
||||
final int target;
|
||||
|
||||
/// 本次是否触发发奖
|
||||
final bool rewardedThisTime;
|
||||
|
||||
/// 本次发放积分数(未发奖为 0)
|
||||
final int rewardPoints;
|
||||
|
||||
factory RewardProgress.fromJson(Map<String, dynamic> json) => RewardProgress(
|
||||
current: (json['current'] as num?)?.toInt() ?? 0,
|
||||
target: (json['target'] as num?)?.toInt() ?? 10,
|
||||
rewardedThisTime: (json['rewarded_this_time'] as bool?) ?? false,
|
||||
rewardPoints: (json['reward_points'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 发表评论响应(《03-接口文档》6.4)
|
||||
class CommentCreateResult {
|
||||
CommentCreateResult({required this.commentId, required this.rewardProgress});
|
||||
|
||||
final int commentId;
|
||||
final RewardProgress rewardProgress;
|
||||
|
||||
factory CommentCreateResult.fromJson(Map<String, dynamic> json) =>
|
||||
CommentCreateResult(
|
||||
commentId: (json['comment_id'] as num?)?.toInt() ?? 0,
|
||||
rewardProgress: RewardProgress.fromJson(
|
||||
(json['reward_progress'] as Map<String, dynamic>?) ?? const {},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/// 会员套餐(《03-接口文档》7.1,价格来自服务端配置)
|
||||
class MembershipPlan {
|
||||
const MembershipPlan({
|
||||
required this.planCode,
|
||||
required this.name,
|
||||
required this.days,
|
||||
required this.points,
|
||||
this.enabled = true,
|
||||
this.desc = '',
|
||||
});
|
||||
|
||||
final String planCode;
|
||||
final String name;
|
||||
|
||||
/// 有效天数;0 = 终身
|
||||
final int days;
|
||||
final int points;
|
||||
final bool enabled;
|
||||
final String desc;
|
||||
|
||||
factory MembershipPlan.fromJson(Map<String, dynamic> json) =>
|
||||
MembershipPlan(
|
||||
planCode: json['plan_code'] as String,
|
||||
name: json['name'] as String,
|
||||
days: (json['days'] as num).toInt(),
|
||||
points: (json['points'] as num).toInt(),
|
||||
enabled: json['enabled'] as bool? ?? true,
|
||||
desc: (json['desc'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 套餐列表响应
|
||||
class PlansResult {
|
||||
const PlansResult({required this.plans, this.benefits = const []});
|
||||
|
||||
final List<MembershipPlan> plans;
|
||||
final List<String> benefits;
|
||||
|
||||
factory PlansResult.fromJson(Map<String, dynamic> json) => PlansResult(
|
||||
plans: ((json['plans'] as List?) ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MembershipPlan.fromJson)
|
||||
.toList(),
|
||||
benefits:
|
||||
((json['benefits'] as List?) ?? const []).cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 我的会员状态(《03-接口文档》7.2)
|
||||
class MyMemberState {
|
||||
const MyMemberState({
|
||||
this.isMember = false,
|
||||
this.isLifetime = false,
|
||||
this.currentPlan,
|
||||
this.planName,
|
||||
this.expireAt,
|
||||
this.remainingDays = 0,
|
||||
this.history = const [],
|
||||
});
|
||||
|
||||
final bool isMember;
|
||||
final bool isLifetime;
|
||||
final String? currentPlan;
|
||||
final String? planName;
|
||||
final String? expireAt;
|
||||
final int remainingDays;
|
||||
final List<MemberHistoryItem> history;
|
||||
|
||||
factory MyMemberState.fromJson(Map<String, dynamic> json) => MyMemberState(
|
||||
isMember: json['is_member'] as bool? ?? false,
|
||||
isLifetime: json['is_lifetime'] as bool? ?? false,
|
||||
currentPlan: json['current_plan'] as String?,
|
||||
planName: json['plan_name'] as String?,
|
||||
expireAt: json['expire_at'] as String?,
|
||||
remainingDays: (json['remaining_days'] as num?)?.toInt() ?? 0,
|
||||
history: ((json['history'] as List?) ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MemberHistoryItem.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 会员开通历史
|
||||
class MemberHistoryItem {
|
||||
const MemberHistoryItem({
|
||||
required this.orderNo,
|
||||
required this.planName,
|
||||
required this.pointsCost,
|
||||
this.startAt = '',
|
||||
this.endAt = '',
|
||||
this.source = 0,
|
||||
});
|
||||
|
||||
final String orderNo;
|
||||
final String planName;
|
||||
final int pointsCost;
|
||||
final String startAt;
|
||||
final String endAt;
|
||||
final int source; // 1 积分开通 2 活动赠送
|
||||
|
||||
String get sourceLabel => source == 2 ? '活动赠送' : '积分开通';
|
||||
|
||||
factory MemberHistoryItem.fromJson(Map<String, dynamic> json) =>
|
||||
MemberHistoryItem(
|
||||
orderNo: json['order_no'] as String,
|
||||
planName: json['plan_name'] as String,
|
||||
pointsCost: (json['points_cost'] as num).toInt(),
|
||||
startAt: (json['start_at'] as String?) ?? '',
|
||||
endAt: (json['end_at'] as String?) ?? '',
|
||||
source: (json['source'] as num?)?.toInt() ?? 1,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/// 用户模型(登录/注册响应的 user 字段与 GET /users/me 的超集,字段可空按需取)
|
||||
class UserModel {
|
||||
UserModel({
|
||||
required this.userId,
|
||||
this.phone,
|
||||
this.email,
|
||||
this.nickname = '',
|
||||
this.avatarUrl = '',
|
||||
this.bio = '',
|
||||
this.gender = 0,
|
||||
this.isMember = false,
|
||||
this.memberPlan,
|
||||
this.memberExpireAt,
|
||||
this.points = 0,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String? phone;
|
||||
final String? email;
|
||||
final String nickname;
|
||||
final String avatarUrl;
|
||||
final String bio;
|
||||
final int gender; // 0未知 1男 2女
|
||||
final bool isMember;
|
||||
final String? memberPlan; // monthly/yearly/lifetime/trial...
|
||||
final String? memberExpireAt; // yyyy-MM-dd HH:mm:ss
|
||||
final int points;
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) => UserModel(
|
||||
userId: (json['user_id'] ?? json['id']) as int,
|
||||
phone: json['phone'] as String?,
|
||||
email: json['email'] as String?,
|
||||
nickname: (json['nickname'] as String?) ?? '',
|
||||
avatarUrl: (json['avatar_url'] as String?) ?? '',
|
||||
bio: (json['bio'] as String?) ?? '',
|
||||
gender: (json['gender'] as num?)?.toInt() ?? 0,
|
||||
isMember: (json['is_member'] as bool?) ?? false,
|
||||
memberPlan: json['member_plan'] as String?,
|
||||
memberExpireAt: json['member_expire_at'] as String?,
|
||||
points: (json['points'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'video_item.dart';
|
||||
|
||||
/// 用户公开主页(《03-接口文档》4.7)
|
||||
class UserPublicProfile {
|
||||
UserPublicProfile({
|
||||
required this.userId,
|
||||
this.nickname = '',
|
||||
this.avatarUrl = '',
|
||||
this.bio = '',
|
||||
this.videoCount = 0,
|
||||
this.totalLikes = 0,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String nickname;
|
||||
final String avatarUrl;
|
||||
final String bio;
|
||||
|
||||
/// 已发布作品数
|
||||
final int videoCount;
|
||||
|
||||
/// 累计获赞
|
||||
final int totalLikes;
|
||||
|
||||
factory UserPublicProfile.fromJson(Map<String, dynamic> json) =>
|
||||
UserPublicProfile(
|
||||
userId: (json['user_id'] as num?)?.toInt() ?? 0,
|
||||
nickname: (json['nickname'] as String?) ?? '',
|
||||
avatarUrl: (json['avatar_url'] as String?) ?? '',
|
||||
bio: (json['bio'] as String?) ?? '',
|
||||
videoCount: (json['video_count'] as num?)?.toInt() ?? 0,
|
||||
totalLikes: (json['total_likes'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 观看历史项(《03-接口文档》6.7:video + watched_at + cost_type)
|
||||
class HistoryItem {
|
||||
HistoryItem({
|
||||
required this.video,
|
||||
this.watchedAt = '',
|
||||
this.costType = 0,
|
||||
});
|
||||
|
||||
final VideoItem video;
|
||||
final String watchedAt;
|
||||
|
||||
/// 计费类型:0 免费额度 / 1 积分 / 2 会员 / 3 重复或本人
|
||||
final int costType;
|
||||
|
||||
String get costLabel => switch (costType) {
|
||||
1 => '积分',
|
||||
2 => '会员',
|
||||
3 => '免费',
|
||||
_ => '免费',
|
||||
};
|
||||
|
||||
factory HistoryItem.fromJson(Map<String, dynamic> json) => HistoryItem(
|
||||
video: VideoItem.fromJson(
|
||||
(json['video'] as Map<String, dynamic>?) ?? const {},
|
||||
),
|
||||
watchedAt: (json['watched_at'] as String?) ?? '',
|
||||
costType: (json['cost_type'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/// Feed 视频项(GET /videos/feed 列表项,对应服务端 video_service 输出)
|
||||
class VideoItem {
|
||||
VideoItem({
|
||||
required this.videoId,
|
||||
this.title = '',
|
||||
this.coverUrl = '',
|
||||
this.videoUrl = '',
|
||||
this.duration = 0,
|
||||
required this.author,
|
||||
this.categoryId = 0,
|
||||
this.viewCount = 0,
|
||||
this.likeCount = 0,
|
||||
this.commentCount = 0,
|
||||
this.favoriteCount = 0,
|
||||
this.tags = const [],
|
||||
this.isLiked = false,
|
||||
this.isFavorited = false,
|
||||
this.description = '',
|
||||
this.status = -1,
|
||||
this.statusText = '',
|
||||
this.rejectReason = '',
|
||||
});
|
||||
|
||||
final int videoId;
|
||||
final String title;
|
||||
final String coverUrl;
|
||||
final String videoUrl; // m3u8
|
||||
final int duration; // 秒
|
||||
final VideoAuthor author;
|
||||
final int categoryId;
|
||||
final int viewCount;
|
||||
final int likeCount;
|
||||
final int commentCount;
|
||||
final int favoriteCount;
|
||||
final List<String> tags;
|
||||
final bool isLiked;
|
||||
final bool isFavorited;
|
||||
final String description;
|
||||
|
||||
/// 作品状态(仅「我的作品」接口返回):-1 未提供;0转码中 1待审核 2已发布 3拒绝 4下架 5转码失败
|
||||
final int status;
|
||||
final String statusText;
|
||||
final String rejectReason;
|
||||
|
||||
/// 拷贝更新(点赞/收藏乐观更新用)
|
||||
VideoItem copyWith({
|
||||
bool? isLiked,
|
||||
bool? isFavorited,
|
||||
int? likeCount,
|
||||
int? favoriteCount,
|
||||
int? commentCount,
|
||||
}) =>
|
||||
VideoItem(
|
||||
videoId: videoId,
|
||||
title: title,
|
||||
coverUrl: coverUrl,
|
||||
videoUrl: videoUrl,
|
||||
duration: duration,
|
||||
author: author,
|
||||
categoryId: categoryId,
|
||||
viewCount: viewCount,
|
||||
likeCount: likeCount ?? this.likeCount,
|
||||
commentCount: commentCount ?? this.commentCount,
|
||||
favoriteCount: favoriteCount ?? this.favoriteCount,
|
||||
tags: tags,
|
||||
isLiked: isLiked ?? this.isLiked,
|
||||
isFavorited: isFavorited ?? this.isFavorited,
|
||||
description: description,
|
||||
status: status,
|
||||
statusText: statusText,
|
||||
rejectReason: rejectReason,
|
||||
);
|
||||
|
||||
factory VideoItem.fromJson(Map<String, dynamic> json) => VideoItem(
|
||||
videoId: json['video_id'] as int,
|
||||
title: (json['title'] as String?) ?? '',
|
||||
coverUrl: (json['cover_url'] as String?) ?? '',
|
||||
videoUrl: (json['video_url'] as String?) ?? '',
|
||||
duration: (json['duration'] as num?)?.toInt() ?? 0,
|
||||
categoryId: (json['category_id'] as num?)?.toInt() ?? 0,
|
||||
author: VideoAuthor.fromJson(
|
||||
(json['author'] as Map<String, dynamic>?) ?? const {},
|
||||
),
|
||||
viewCount: (json['view_count'] as num?)?.toInt() ?? 0,
|
||||
likeCount: (json['like_count'] as num?)?.toInt() ?? 0,
|
||||
commentCount: (json['comment_count'] as num?)?.toInt() ?? 0,
|
||||
favoriteCount: (json['favorite_count'] as num?)?.toInt() ?? 0,
|
||||
tags: ((json['tags'] as List?) ?? const [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
isLiked: (json['is_liked'] as bool?) ?? false,
|
||||
isFavorited: (json['is_favorited'] as bool?) ?? false,
|
||||
description: (json['description'] as String?) ?? '',
|
||||
status: (json['status'] as num?)?.toInt() ?? -1,
|
||||
statusText: (json['status_text'] as String?) ?? '',
|
||||
rejectReason: (json['reject_reason'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 作者简要信息
|
||||
class VideoAuthor {
|
||||
VideoAuthor({
|
||||
required this.userId,
|
||||
this.nickname = '',
|
||||
this.avatarUrl = '',
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String nickname;
|
||||
final String avatarUrl;
|
||||
|
||||
factory VideoAuthor.fromJson(Map<String, dynamic> json) => VideoAuthor(
|
||||
userId: (json['user_id'] as num?)?.toInt() ?? 0,
|
||||
nickname: (json['nickname'] as String?) ?? '',
|
||||
avatarUrl: (json['avatar_url'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/// 观看上报(计费)响应模型(《03-接口文档》5.3)
|
||||
class WatchResult {
|
||||
WatchResult({
|
||||
required this.costType,
|
||||
required this.costPoints,
|
||||
required this.remainingPoints,
|
||||
required this.freeRemainingToday,
|
||||
});
|
||||
|
||||
/// 计费类型:0 占用免费额度 / 1 积分扣减 / 2 会员免费 / 3 重复观看或本人作品
|
||||
final int costType;
|
||||
|
||||
/// 本次扣除积分(cost_type=1 时为 1,其余为 0)
|
||||
final int costPoints;
|
||||
|
||||
/// 当前剩余积分
|
||||
final int remainingPoints;
|
||||
|
||||
/// 今日免费观看剩余次数
|
||||
final int freeRemainingToday;
|
||||
|
||||
bool get isPointsCost => costType == 1;
|
||||
|
||||
factory WatchResult.fromJson(Map<String, dynamic> json) => WatchResult(
|
||||
costType: (json['cost_type'] as num?)?.toInt() ?? 0,
|
||||
costPoints: (json['cost_points'] as num?)?.toInt() ?? 0,
|
||||
remainingPoints: (json['remaining_points'] as num?)?.toInt() ?? 0,
|
||||
freeRemainingToday: (json['free_remaining_today'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/asset_service.dart';
|
||||
|
||||
/// 资产页状态:资产钱包 + 收益明细(biz_type 筛选 + 触底分页)
|
||||
class AssetState {
|
||||
const AssetState({
|
||||
this.wallet,
|
||||
this.records = const [],
|
||||
this.recordsTotal = 0,
|
||||
this.bizType,
|
||||
this.isLoading = false,
|
||||
this.loadFailed = false,
|
||||
this.loadingMore = false,
|
||||
});
|
||||
|
||||
final AssetWallet? wallet;
|
||||
final List<AssetRecord> records;
|
||||
final int recordsTotal;
|
||||
|
||||
/// null = 全部
|
||||
final int? bizType;
|
||||
final bool isLoading;
|
||||
final bool loadFailed;
|
||||
final bool loadingMore;
|
||||
|
||||
bool get hasMore => records.length < recordsTotal;
|
||||
|
||||
AssetState copyWith({
|
||||
AssetWallet? wallet,
|
||||
List<AssetRecord>? records,
|
||||
int? recordsTotal,
|
||||
int? Function()? bizType,
|
||||
bool? isLoading,
|
||||
bool? loadFailed,
|
||||
bool? loadingMore,
|
||||
}) =>
|
||||
AssetState(
|
||||
wallet: wallet ?? this.wallet,
|
||||
records: records ?? this.records,
|
||||
recordsTotal: recordsTotal ?? this.recordsTotal,
|
||||
bizType: bizType != null ? bizType() : this.bizType,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
loadFailed: loadFailed ?? this.loadFailed,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
);
|
||||
}
|
||||
|
||||
class AssetNotifier extends StateNotifier<AssetState> {
|
||||
AssetNotifier() : super(const AssetState()) {
|
||||
load();
|
||||
}
|
||||
|
||||
static const _pageSize = 20;
|
||||
|
||||
Future<void> load() async {
|
||||
state = state.copyWith(isLoading: true, loadFailed: false);
|
||||
try {
|
||||
final wallet = await AssetService.fetchAssets();
|
||||
final page = await AssetService.fetchRecords(
|
||||
page: 1,
|
||||
pageSize: _pageSize,
|
||||
bizType: state.bizType,
|
||||
);
|
||||
state = state.copyWith(
|
||||
wallet: wallet,
|
||||
records: page.items,
|
||||
recordsTotal: page.total,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false, loadFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换 biz_type 筛选并重载明细
|
||||
Future<void> setBizType(int? bizType) async {
|
||||
if (bizType == state.bizType) return;
|
||||
state = state.copyWith(bizType: () => bizType);
|
||||
await load();
|
||||
}
|
||||
|
||||
/// 触底加载下一页
|
||||
Future<void> loadMore() async {
|
||||
if (state.loadingMore || !state.hasMore || state.isLoading) return;
|
||||
state = state.copyWith(loadingMore: true);
|
||||
try {
|
||||
final page = await AssetService.fetchRecords(
|
||||
page: state.records.length ~/ _pageSize + 1,
|
||||
pageSize: _pageSize,
|
||||
bizType: state.bizType,
|
||||
);
|
||||
state = state.copyWith(
|
||||
records: [...state.records, ...page.items],
|
||||
recordsTotal: page.total,
|
||||
loadingMore: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(loadingMore: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final assetProvider = StateNotifierProvider.autoDispose<AssetNotifier, AssetState>(
|
||||
(_) => AssetNotifier(),
|
||||
);
|
||||
|
||||
/// 提现记录状态(触底分页)
|
||||
class WithdrawRecordsState {
|
||||
const WithdrawRecordsState({
|
||||
this.items = const [],
|
||||
this.total = 0,
|
||||
this.isLoading = false,
|
||||
this.loadFailed = false,
|
||||
this.loadingMore = false,
|
||||
});
|
||||
|
||||
final List<WithdrawRecord> items;
|
||||
final int total;
|
||||
final bool isLoading;
|
||||
final bool loadFailed;
|
||||
final bool loadingMore;
|
||||
|
||||
bool get hasMore => items.length < total;
|
||||
|
||||
WithdrawRecordsState copyWith({
|
||||
List<WithdrawRecord>? items,
|
||||
int? total,
|
||||
bool? isLoading,
|
||||
bool? loadFailed,
|
||||
bool? loadingMore,
|
||||
}) =>
|
||||
WithdrawRecordsState(
|
||||
items: items ?? this.items,
|
||||
total: total ?? this.total,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
loadFailed: loadFailed ?? this.loadFailed,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
);
|
||||
}
|
||||
|
||||
class WithdrawRecordsNotifier extends StateNotifier<WithdrawRecordsState> {
|
||||
WithdrawRecordsNotifier() : super(const WithdrawRecordsState()) {
|
||||
load();
|
||||
}
|
||||
|
||||
static const _pageSize = 20;
|
||||
|
||||
Future<void> load() async {
|
||||
state = state.copyWith(isLoading: true, loadFailed: false);
|
||||
try {
|
||||
final page = await AssetService.fetchWithdrawRecords(pageSize: _pageSize);
|
||||
state = state.copyWith(
|
||||
items: page.items,
|
||||
total: page.total,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false, loadFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (state.loadingMore || !state.hasMore || state.isLoading) return;
|
||||
state = state.copyWith(loadingMore: true);
|
||||
try {
|
||||
final page = await AssetService.fetchWithdrawRecords(
|
||||
page: state.items.length ~/ _pageSize + 1,
|
||||
pageSize: _pageSize,
|
||||
);
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
total: page.total,
|
||||
loadingMore: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(loadingMore: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final withdrawRecordsProvider = StateNotifierProvider.autoDispose<
|
||||
WithdrawRecordsNotifier, WithdrawRecordsState>(
|
||||
(_) => WithdrawRecordsNotifier(),
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/network/api_client.dart';
|
||||
import '../core/storage/token_store.dart';
|
||||
import '../models/user_model.dart';
|
||||
import '../services/user_service.dart';
|
||||
|
||||
/// 登录态:未登录 / 已登录(含用户信息)
|
||||
/// Token 本体在 TokenStore(安全存储 + 内存缓存),此处只持有用户模型与状态。
|
||||
class AuthState {
|
||||
const AuthState({this.user, this.restored = false});
|
||||
|
||||
final UserModel? user;
|
||||
final bool restored; // 是否已完成启动恢复(避免启动期误跳登录)
|
||||
|
||||
bool get isLoggedIn => user != null && TokenStore.hasToken;
|
||||
|
||||
AuthState copyWith({UserModel? user, bool? restored}) => AuthState(
|
||||
user: user ?? this.user,
|
||||
restored: restored ?? this.restored,
|
||||
);
|
||||
}
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
AuthNotifier() : super(const AuthState(restored: false)) {
|
||||
// 会话过期(401 刷新失败)→ 清空登录态
|
||||
ApiClient.onSessionExpired = _onSessionExpired;
|
||||
}
|
||||
|
||||
/// 启动恢复:读取安全存储中的 Token,有效则拉取用户信息
|
||||
Future<void> restore() async {
|
||||
await TokenStore.restore();
|
||||
if (!TokenStore.hasToken) {
|
||||
state = state.copyWith(restored: true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final user = await UserService.fetchMe();
|
||||
state = AuthState(user: user, restored: true);
|
||||
} catch (_) {
|
||||
// Token 失效(1002 等):本地清理,回游客态
|
||||
await TokenStore.clear();
|
||||
state = AuthState(restored: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录/注册成功:Token 已由 AuthService 写入 TokenStore,此处仅刷新用户
|
||||
Future<void> onLoginSuccess(UserModel user) async {
|
||||
state = AuthState(user: user, restored: true);
|
||||
}
|
||||
|
||||
/// 修改资料/头像后同步当前用户(不改变登录态与 Token)
|
||||
void updateUser(UserModel user) => state = state.copyWith(user: user);
|
||||
|
||||
/// 退出登录:服务端注销 + 本地清理
|
||||
Future<void> logout() async => _onSessionExpired();
|
||||
|
||||
Future<void> _onSessionExpired() async {
|
||||
await TokenStore.clear();
|
||||
state = AuthState(restored: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局登录态 Provider
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
return AuthNotifier();
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/comment_item.dart';
|
||||
import '../services/comment_service.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
/// 回复目标(null 表示直接评论视频)
|
||||
class ReplyTarget {
|
||||
const ReplyTarget({
|
||||
required this.parentId,
|
||||
required this.replyToUserId,
|
||||
required this.nickname,
|
||||
});
|
||||
|
||||
/// 一级评论 ID(楼中楼统一挂一级楼下)
|
||||
final int parentId;
|
||||
|
||||
/// 被回复人 ID(回复一级评论时等于其作者)
|
||||
final int replyToUserId;
|
||||
|
||||
/// 被回复人昵称(输入框提示用)
|
||||
final String nickname;
|
||||
}
|
||||
|
||||
/// 评论区状态
|
||||
class CommentState {
|
||||
const CommentState({
|
||||
this.items = const [],
|
||||
this.total = 0,
|
||||
this.isLoading = false,
|
||||
this.isLoadingMore = false,
|
||||
this.loadFailed = false,
|
||||
this.page = 1,
|
||||
this.replies = const {},
|
||||
this.repliesLoading = const {},
|
||||
this.replyTarget,
|
||||
this.posting = false,
|
||||
});
|
||||
|
||||
/// 一级评论列表
|
||||
final List<CommentItem> items;
|
||||
final int total;
|
||||
final bool isLoading;
|
||||
final bool isLoadingMore;
|
||||
|
||||
/// 首屏加载失败
|
||||
final bool loadFailed;
|
||||
final int page;
|
||||
|
||||
/// 已展开的一级评论的二级回复列表
|
||||
final Map<int, List<CommentItem>> replies;
|
||||
|
||||
/// 回复加载中的一级评论 ID
|
||||
final Set<int> repliesLoading;
|
||||
|
||||
/// 当前回复目标(null = 评论视频本身)
|
||||
final ReplyTarget? replyTarget;
|
||||
|
||||
/// 发表中(防重复提交)
|
||||
final bool posting;
|
||||
|
||||
static const int pageSize = 20;
|
||||
|
||||
bool get hasMore => items.length < total;
|
||||
|
||||
CommentState copyWith({
|
||||
List<CommentItem>? items,
|
||||
int? total,
|
||||
bool? isLoading,
|
||||
bool? isLoadingMore,
|
||||
bool? loadFailed,
|
||||
int? page,
|
||||
Map<int, List<CommentItem>>? replies,
|
||||
Set<int>? repliesLoading,
|
||||
ReplyTarget? Function()? replyTarget,
|
||||
bool? posting,
|
||||
}) =>
|
||||
CommentState(
|
||||
items: items ?? this.items,
|
||||
total: total ?? this.total,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
loadFailed: loadFailed ?? this.loadFailed,
|
||||
page: page ?? this.page,
|
||||
replies: replies ?? this.replies,
|
||||
repliesLoading: repliesLoading ?? this.repliesLoading,
|
||||
replyTarget: replyTarget != null ? replyTarget() : this.replyTarget,
|
||||
posting: posting ?? this.posting,
|
||||
);
|
||||
}
|
||||
|
||||
/// 评论区 Notifier(按 videoId 家族隔离)
|
||||
class CommentNotifier extends StateNotifier<CommentState> {
|
||||
CommentNotifier(this._ref, this.videoId) : super(const CommentState()) {
|
||||
load();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
final int videoId;
|
||||
|
||||
/// 首屏加载
|
||||
Future<void> load() async {
|
||||
state = state.copyWith(isLoading: true, loadFailed: false);
|
||||
try {
|
||||
final page = await CommentService.list(videoId);
|
||||
state = state.copyWith(
|
||||
items: page.items,
|
||||
total: page.total,
|
||||
isLoading: false,
|
||||
page: 1,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false, loadFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 触底加载更多一级评论
|
||||
Future<void> loadMore() async {
|
||||
if (!state.hasMore || state.isLoadingMore || state.isLoading) return;
|
||||
state = state.copyWith(isLoadingMore: true);
|
||||
try {
|
||||
final page = await CommentService.list(videoId, page: state.page + 1);
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
total: page.total,
|
||||
isLoadingMore: false,
|
||||
page: state.page + 1,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoadingMore: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 展开/收起二级回复;首次展开时加载(一次拉 50 条,超出部分显示总数)
|
||||
Future<void> toggleReplies(CommentItem comment) async {
|
||||
final replies = Map<int, List<CommentItem>>.from(state.replies);
|
||||
if (replies.containsKey(comment.commentId)) {
|
||||
replies.remove(comment.commentId);
|
||||
state = state.copyWith(replies: replies);
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(
|
||||
repliesLoading: {...state.repliesLoading, comment.commentId},
|
||||
);
|
||||
try {
|
||||
final page = await CommentService.list(
|
||||
videoId,
|
||||
parentId: comment.commentId,
|
||||
pageSize: 50,
|
||||
);
|
||||
state = state.copyWith(
|
||||
replies: {...state.replies, comment.commentId: page.items},
|
||||
repliesLoading: {...state.repliesLoading}..remove(comment.commentId),
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(
|
||||
repliesLoading: {...state.repliesLoading}..remove(comment.commentId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置/清除回复目标
|
||||
void setReplyTarget(ReplyTarget? target) =>
|
||||
state = state.copyWith(replyTarget: () => target);
|
||||
|
||||
/// 发表评论/回复。成功返回结果(含奖励进度),失败抛异常由 UI 提示
|
||||
Future<CommentCreateResult> post(String content) async {
|
||||
final user = _ref.read(authProvider).user;
|
||||
final target = state.replyTarget;
|
||||
state = state.copyWith(posting: true);
|
||||
try {
|
||||
final result = await CommentService.create(
|
||||
videoId,
|
||||
content,
|
||||
parentId: target?.parentId,
|
||||
replyToUserId: target?.replyToUserId,
|
||||
);
|
||||
final item = CommentItem(
|
||||
commentId: result.commentId,
|
||||
userId: user?.userId ?? 0,
|
||||
nickname: user?.nickname ?? '我',
|
||||
avatarUrl: user?.avatarUrl ?? '',
|
||||
content: content,
|
||||
createdAt: '刚刚',
|
||||
);
|
||||
if (target == null) {
|
||||
// 一级评论:插入顶部
|
||||
state = state.copyWith(
|
||||
items: [item, ...state.items],
|
||||
total: state.total + 1,
|
||||
posting: false,
|
||||
);
|
||||
} else {
|
||||
// 回复:插入已展开列表,父评论 replyCount+1
|
||||
final replies = Map<int, List<CommentItem>>.from(state.replies);
|
||||
if (replies.containsKey(target.parentId)) {
|
||||
replies[target.parentId] = [item, ...replies[target.parentId]!];
|
||||
}
|
||||
state = state.copyWith(
|
||||
replies: replies,
|
||||
items: [
|
||||
for (final c in state.items)
|
||||
c.commentId == target.parentId
|
||||
? c.copyWith(replyCount: c.replyCount + 1)
|
||||
: c,
|
||||
],
|
||||
total: state.total + 1,
|
||||
posting: false,
|
||||
replyTarget: () => null,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (_) {
|
||||
state = state.copyWith(posting: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除评论:parentId=0 为一级评论,否则为某一级楼下的回复
|
||||
Future<void> remove(CommentItem comment, {int parentId = 0}) async {
|
||||
await CommentService.delete(comment.commentId);
|
||||
if (parentId == 0) {
|
||||
final replies = Map<int, List<CommentItem>>.from(state.replies)
|
||||
..remove(comment.commentId);
|
||||
state = state.copyWith(
|
||||
items: state.items.where((c) => c.commentId != comment.commentId).toList(),
|
||||
total: (state.total - 1).clamp(0, 1 << 31),
|
||||
replies: replies,
|
||||
);
|
||||
} else {
|
||||
final replies = Map<int, List<CommentItem>>.from(state.replies);
|
||||
replies[parentId] = (replies[parentId] ?? [])
|
||||
.where((c) => c.commentId != comment.commentId)
|
||||
.toList();
|
||||
state = state.copyWith(
|
||||
replies: replies,
|
||||
items: [
|
||||
for (final c in state.items)
|
||||
c.commentId == parentId
|
||||
? c.copyWith(replyCount: (c.replyCount - 1).clamp(0, 1 << 31))
|
||||
: c,
|
||||
],
|
||||
total: (state.total - 1).clamp(0, 1 << 31),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论区 Provider(按视频 ID 家族)
|
||||
final commentProvider =
|
||||
StateNotifierProvider.family<CommentNotifier, CommentState, int>(
|
||||
(ref, videoId) => CommentNotifier(ref, videoId),
|
||||
);
|
||||
@@ -0,0 +1,290 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/config/test_stream.dart';
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../core/player/feed_player_pool.dart';
|
||||
import '../core/storage/watch_dedup_store.dart';
|
||||
import '../models/video_item.dart';
|
||||
import '../services/video_service.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
/// Feed 策略(对应《01-开发文档》2.5.6)
|
||||
enum FeedStrategy {
|
||||
recommend('recommend', '推荐'),
|
||||
latest('latest', '最新'),
|
||||
hot('hot', '热度');
|
||||
|
||||
const FeedStrategy(this.apiValue, this.label);
|
||||
|
||||
final String apiValue;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast)
|
||||
sealed class FeedEvent {
|
||||
const FeedEvent();
|
||||
}
|
||||
|
||||
/// 扣积分提示:顶部 Toast「本次消耗 N 积分」
|
||||
class FeedToastEvent extends FeedEvent {
|
||||
const FeedToastEvent(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// 游客超限:弹登录引导
|
||||
class FeedGuestLimitEvent extends FeedEvent {
|
||||
const FeedGuestLimitEvent();
|
||||
}
|
||||
|
||||
/// 积分不足:弹「去充值 / 开通会员」
|
||||
class FeedPointsInsufficientEvent extends FeedEvent {
|
||||
const FeedPointsInsufficientEvent(this.points);
|
||||
final int points;
|
||||
}
|
||||
|
||||
/// Feed 状态
|
||||
class FeedState {
|
||||
const FeedState({
|
||||
this.items = const [],
|
||||
this.nextCursor,
|
||||
this.currentIndex = 0,
|
||||
this.isRefreshing = false,
|
||||
this.isLoadingMore = false,
|
||||
this.loadFailed = false,
|
||||
this.freeRemainingToday,
|
||||
});
|
||||
|
||||
final List<VideoItem> items;
|
||||
final String? nextCursor;
|
||||
final int currentIndex;
|
||||
final bool isRefreshing;
|
||||
final bool isLoadingMore;
|
||||
|
||||
/// 首屏加载失败(网络异常)
|
||||
final bool loadFailed;
|
||||
|
||||
/// 今日免费剩余次数(首次 watch 响应后落地,null 时不显示提示条)
|
||||
final int? freeRemainingToday;
|
||||
|
||||
bool get hasMore => nextCursor != null;
|
||||
|
||||
FeedState copyWith({
|
||||
List<VideoItem>? items,
|
||||
String? Function()? nextCursor,
|
||||
int? currentIndex,
|
||||
bool? isRefreshing,
|
||||
bool? isLoadingMore,
|
||||
bool? loadFailed,
|
||||
int? Function()? freeRemainingToday,
|
||||
}) =>
|
||||
FeedState(
|
||||
items: items ?? this.items,
|
||||
nextCursor: nextCursor != null ? nextCursor() : this.nextCursor,
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
loadFailed: loadFailed ?? this.loadFailed,
|
||||
freeRemainingToday: freeRemainingToday != null
|
||||
? freeRemainingToday()
|
||||
: this.freeRemainingToday,
|
||||
);
|
||||
}
|
||||
|
||||
class FeedNotifier extends StateNotifier<FeedState> {
|
||||
FeedNotifier(this._ref, this.strategy) : super(const FeedState()) {
|
||||
pool = FeedPlayerPool(onWatchComplete: _onWatchComplete)..init();
|
||||
refresh();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
final FeedStrategy strategy;
|
||||
|
||||
late final FeedPlayerPool pool;
|
||||
|
||||
final _eventController = StreamController<FeedEvent>.broadcast();
|
||||
|
||||
/// UI 事件流(FeedScreen 监听后弹窗/Toast)
|
||||
Stream<FeedEvent> get events => _eventController.stream;
|
||||
|
||||
WatchDedupStore get _dedup => WatchDedupStore.I;
|
||||
|
||||
/// 刷新(首屏 / 下拉 / 切策略)
|
||||
Future<void> refresh() async {
|
||||
state = state.copyWith(isRefreshing: true, loadFailed: false);
|
||||
try {
|
||||
final page = await VideoService.fetchFeed(type: strategy.apiValue);
|
||||
state = FeedState(
|
||||
items: page.items,
|
||||
nextCursor: page.nextCursor,
|
||||
freeRemainingToday: state.freeRemainingToday,
|
||||
);
|
||||
if (page.items.isNotEmpty) {
|
||||
_bindAndReport(0);
|
||||
}
|
||||
} catch (_) {
|
||||
state = state.copyWith(isRefreshing: false, loadFailed: true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载更多(游标分页)
|
||||
Future<void> loadMore() async {
|
||||
if (!state.hasMore || state.isLoadingMore) return;
|
||||
state = state.copyWith(isLoadingMore: true);
|
||||
try {
|
||||
final page = await VideoService.fetchFeed(
|
||||
type: strategy.apiValue,
|
||||
cursor: state.nextCursor,
|
||||
);
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
nextCursor: () => page.nextCursor,
|
||||
isLoadingMore: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoadingMore: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
|
||||
void onPageChanged(int index) {
|
||||
state = state.copyWith(currentIndex: index);
|
||||
_bindAndReport(index);
|
||||
// 接近尾部预取下一页
|
||||
if (index >= state.items.length - 3) loadMore();
|
||||
}
|
||||
|
||||
/// 绑定播放窗口 + 观看计费上报
|
||||
Future<void> _bindAndReport(int index) async {
|
||||
if (index < 0 || index >= state.items.length) return;
|
||||
final items = state.items;
|
||||
pool.bindWindow(
|
||||
index,
|
||||
[for (final v in items) (videoId: v.videoId, url: resolvePlayUrl(v))],
|
||||
);
|
||||
final item = items[index];
|
||||
|
||||
// 同日同视频只上报一次(先标记再请求,失败不回滚,防重复扣费)
|
||||
if (_dedup.hasWatched(item.videoId)) return;
|
||||
await _dedup.markWatched(item.videoId);
|
||||
|
||||
try {
|
||||
final result = await VideoService.reportWatch(item.videoId);
|
||||
if (result.isPointsCost && result.costPoints > 0) {
|
||||
_eventController.add(FeedToastEvent('本次消耗 ${result.costPoints} 积分'));
|
||||
}
|
||||
state = state.copyWith(
|
||||
freeRemainingToday: () => result.freeRemainingToday,
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
pool.pauseCurrent();
|
||||
if (e.isGuestLimit) {
|
||||
_eventController.add(const FeedGuestLimitEvent());
|
||||
} else if (e.isPointsInsufficient) {
|
||||
final points = (e.data?['points'] as num?)?.toInt() ?? 0;
|
||||
_eventController.add(FeedPointsInsufficientEvent(points));
|
||||
}
|
||||
// 其他错误静默,不影响播放
|
||||
} catch (_) {
|
||||
// 网络异常静默,播放不中断
|
||||
}
|
||||
}
|
||||
|
||||
/// 完整观看上报(pool ≥95%/播完回调):服务端同日去重,本地再省一次请求
|
||||
Future<void> _onWatchComplete(int videoId, int watchDuration) async {
|
||||
if (watchDuration <= 0 || _dedup.hasCompleted(videoId)) return;
|
||||
await _dedup.markCompleted(videoId);
|
||||
try {
|
||||
await VideoService.reportComplete(videoId, watchDuration);
|
||||
} catch (_) {
|
||||
// credited=false 与网络异常均无需 UI 处理
|
||||
}
|
||||
}
|
||||
|
||||
/// 点赞(乐观更新,失败回滚);双击已点赞时仅播动画不调用
|
||||
Future<void> toggleLike(int videoId) async {
|
||||
if (!_checkLoggedIn()) return;
|
||||
final index = _indexOf(videoId);
|
||||
if (index < 0) return;
|
||||
final old = state.items[index];
|
||||
_replaceItem(
|
||||
index,
|
||||
old.copyWith(
|
||||
isLiked: !old.isLiked,
|
||||
likeCount: old.isLiked ? old.likeCount - 1 : old.likeCount + 1,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await VideoService.toggleLike(videoId);
|
||||
} catch (_) {
|
||||
_replaceItem(index, old); // 回滚
|
||||
_eventController.add(const FeedToastEvent('操作失败,请重试'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论数增减同步(评论弹层发表/删除成功后回调,本地计数 ±1)
|
||||
void adjustCommentCount(int videoId, int delta) {
|
||||
final index = _indexOf(videoId);
|
||||
if (index < 0) return;
|
||||
final old = state.items[index];
|
||||
_replaceItem(
|
||||
index,
|
||||
old.copyWith(commentCount: (old.commentCount + delta).clamp(0, 1 << 31)),
|
||||
);
|
||||
}
|
||||
|
||||
/// 收藏(乐观更新,失败回滚)
|
||||
Future<void> toggleFavorite(int videoId) async {
|
||||
if (!_checkLoggedIn()) return;
|
||||
final index = _indexOf(videoId);
|
||||
if (index < 0) return;
|
||||
final old = state.items[index];
|
||||
_replaceItem(
|
||||
index,
|
||||
old.copyWith(
|
||||
isFavorited: !old.isFavorited,
|
||||
favoriteCount:
|
||||
old.isFavorited ? old.favoriteCount - 1 : old.favoriteCount + 1,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await VideoService.toggleFavorite(videoId);
|
||||
} catch (_) {
|
||||
_replaceItem(index, old);
|
||||
_eventController.add(const FeedToastEvent('操作失败,请重试'));
|
||||
}
|
||||
}
|
||||
|
||||
bool _checkLoggedIn() {
|
||||
final loggedIn = _ref.read(authProvider).isLoggedIn;
|
||||
if (!loggedIn) {
|
||||
// 未登录:发事件由 UI 跳登录页(复用 Toast 事件表达意图太重,直接在这里不管 UI,
|
||||
// 由 interaction_bar 的点击回调先判登录态。此处双保险静默即可)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int _indexOf(int videoId) =>
|
||||
state.items.indexWhere((v) => v.videoId == videoId);
|
||||
|
||||
void _replaceItem(int index, VideoItem item) {
|
||||
final items = [...state.items];
|
||||
items[index] = item;
|
||||
state = state.copyWith(items: items);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventController.close();
|
||||
pool.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed Provider:按策略各持一份(切 Tab 互不污染,切回保持滚动位置)
|
||||
final feedProvider = StateNotifierProvider.family<FeedNotifier, FeedState, FeedStrategy>(
|
||||
(ref, strategy) => FeedNotifier(ref, strategy),
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../models/membership_models.dart';
|
||||
import '../services/membership_service.dart';
|
||||
import '../services/user_service.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
/// 会员中心状态
|
||||
class MemberState {
|
||||
const MemberState({
|
||||
this.plans = const [],
|
||||
this.benefits = const [],
|
||||
this.me,
|
||||
this.isLoading = false,
|
||||
this.loadFailed = false,
|
||||
this.redeeming = false,
|
||||
});
|
||||
|
||||
final List<MembershipPlan> plans;
|
||||
final List<String> benefits;
|
||||
final MyMemberState? me;
|
||||
final bool isLoading;
|
||||
final bool loadFailed;
|
||||
final bool redeeming;
|
||||
|
||||
MemberState copyWith({
|
||||
List<MembershipPlan>? plans,
|
||||
List<String>? benefits,
|
||||
MyMemberState? me,
|
||||
bool? isLoading,
|
||||
bool? loadFailed,
|
||||
bool? redeeming,
|
||||
}) =>
|
||||
MemberState(
|
||||
plans: plans ?? this.plans,
|
||||
benefits: benefits ?? this.benefits,
|
||||
me: me ?? this.me,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
loadFailed: loadFailed ?? this.loadFailed,
|
||||
redeeming: redeeming ?? this.redeeming,
|
||||
);
|
||||
}
|
||||
|
||||
/// 会员中心 Notifier:套餐列表 + 我的会员状态 + 积分开通
|
||||
class MemberNotifier extends StateNotifier<MemberState> {
|
||||
MemberNotifier(this._ref) : super(const MemberState()) {
|
||||
load();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
|
||||
Future<void> load() async {
|
||||
state = state.copyWith(isLoading: true, loadFailed: false);
|
||||
try {
|
||||
final plans = await MembershipService.fetchPlans();
|
||||
MyMemberState? me;
|
||||
// 游客无会员状态接口权限,未登录时跳过
|
||||
if (_ref.read(authProvider).isLoggedIn) {
|
||||
me = await MembershipService.fetchMe();
|
||||
}
|
||||
state = state.copyWith(
|
||||
plans: plans.plans,
|
||||
benefits: plans.benefits,
|
||||
me: me,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false, loadFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 积分开通会员。
|
||||
/// 成功返回结果文案;失败抛 [ApiException] 由 UI 提示(2001 积分不足 / 2004 终身卡重复开通)。
|
||||
Future<RedeemResult> redeem(String planCode) async {
|
||||
state = state.copyWith(redeeming: true);
|
||||
try {
|
||||
final result = await MembershipService.redeem(planCode);
|
||||
// 刷新我的会员状态 + 全局用户信息(积分/会员标识已变化)
|
||||
final me = await MembershipService.fetchMe();
|
||||
final user = await UserService.fetchMe();
|
||||
state = state.copyWith(me: me, redeeming: false);
|
||||
await _ref.read(authProvider.notifier).onLoginSuccess(user);
|
||||
return result;
|
||||
} catch (_) {
|
||||
state = state.copyWith(redeeming: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final memberProvider = StateNotifierProvider<MemberNotifier, MemberState>(
|
||||
MemberNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/notification_service.dart';
|
||||
|
||||
/// 未读消息数(我的页角标):登录后加载,标记已读后同步
|
||||
final unreadCountProvider =
|
||||
StateNotifierProvider<UnreadCountNotifier, int>((_) => UnreadCountNotifier());
|
||||
|
||||
class UnreadCountNotifier extends StateNotifier<int> {
|
||||
UnreadCountNotifier() : super(0);
|
||||
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
state = await NotificationService.fetchUnreadCount();
|
||||
} catch (_) {
|
||||
// 未登录/网络异常:保持原值
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地 decrement(标记单条已读后角标即时 -1,后台再 refresh 校准)
|
||||
void decrement([int count = 1]) {
|
||||
state = state > count ? state - count : 0;
|
||||
}
|
||||
|
||||
void clear() => state = 0;
|
||||
}
|
||||
|
||||
/// 通知列表状态:type 筛选 + 触底分页
|
||||
class NotificationsState {
|
||||
const NotificationsState({
|
||||
this.items = const [],
|
||||
this.total = 0,
|
||||
this.type,
|
||||
this.isLoading = false,
|
||||
this.loadFailed = false,
|
||||
this.loadingMore = false,
|
||||
this.markingAll = false,
|
||||
});
|
||||
|
||||
final List<NotificationItem> items;
|
||||
final int total;
|
||||
|
||||
/// null = 全部
|
||||
final int? type;
|
||||
final bool isLoading;
|
||||
final bool loadFailed;
|
||||
final bool loadingMore;
|
||||
final bool markingAll;
|
||||
|
||||
bool get hasMore => items.length < total;
|
||||
int get unreadCount => items.where((e) => !e.isRead).length;
|
||||
|
||||
NotificationsState copyWith({
|
||||
List<NotificationItem>? items,
|
||||
int? total,
|
||||
int? Function()? type,
|
||||
bool? isLoading,
|
||||
bool? loadFailed,
|
||||
bool? loadingMore,
|
||||
bool? markingAll,
|
||||
}) =>
|
||||
NotificationsState(
|
||||
items: items ?? this.items,
|
||||
total: total ?? this.total,
|
||||
type: type != null ? type() : this.type,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
loadFailed: loadFailed ?? this.loadFailed,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
markingAll: markingAll ?? this.markingAll,
|
||||
);
|
||||
}
|
||||
|
||||
class NotificationsNotifier extends StateNotifier<NotificationsState> {
|
||||
NotificationsNotifier(this._ref) : super(const NotificationsState()) {
|
||||
load();
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
static const _pageSize = 20;
|
||||
|
||||
Future<void> load() async {
|
||||
state = state.copyWith(isLoading: true, loadFailed: false);
|
||||
try {
|
||||
final page = await NotificationService.fetchNotifications(
|
||||
page: 1,
|
||||
pageSize: _pageSize,
|
||||
type: state.type,
|
||||
);
|
||||
state = state.copyWith(
|
||||
items: page.items,
|
||||
total: page.total,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false, loadFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换类型筛选并重载
|
||||
Future<void> setType(int? type) async {
|
||||
if (type == state.type) return;
|
||||
state = state.copyWith(type: () => type);
|
||||
await load();
|
||||
}
|
||||
|
||||
/// 触底加载下一页
|
||||
Future<void> loadMore() async {
|
||||
if (state.loadingMore || !state.hasMore || state.isLoading) return;
|
||||
state = state.copyWith(loadingMore: true);
|
||||
try {
|
||||
final page = await NotificationService.fetchNotifications(
|
||||
page: state.items.length ~/ _pageSize + 1,
|
||||
pageSize: _pageSize,
|
||||
type: state.type,
|
||||
);
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
total: page.total,
|
||||
loadingMore: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(loadingMore: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条已读:乐观更新本地,失败静默(下次进入重拉)
|
||||
Future<void> markOneRead(NotificationItem item) async {
|
||||
if (item.isRead) return;
|
||||
state = state.copyWith(
|
||||
items: [
|
||||
for (final e in state.items)
|
||||
if (e.id == item.id) e.copyWith(isRead: true) else e,
|
||||
],
|
||||
);
|
||||
_ref.read(unreadCountProvider.notifier).decrement();
|
||||
try {
|
||||
await NotificationService.markRead(ids: [item.id]);
|
||||
} catch (_) {
|
||||
// 静默:以服务端为准,下次进入页面会重拉
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部已读
|
||||
Future<void> markAllRead() async {
|
||||
if (state.markingAll) return;
|
||||
state = state.copyWith(markingAll: true);
|
||||
try {
|
||||
await NotificationService.markRead(all: true);
|
||||
if (!mounted) return;
|
||||
state = state.copyWith(
|
||||
items: [for (final e in state.items) e.copyWith(isRead: true)],
|
||||
markingAll: false,
|
||||
);
|
||||
_ref.read(unreadCountProvider.notifier).clear();
|
||||
} catch (_) {
|
||||
if (mounted) state = state.copyWith(markingAll: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final notificationsProvider =
|
||||
StateNotifierProvider.autoDispose<NotificationsNotifier, NotificationsState>(
|
||||
(ref) => NotificationsNotifier(ref),
|
||||
);
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/payment_service.dart';
|
||||
|
||||
/// 充值页状态:套餐 + 选中档位/自定义金额 + 选中支付方式 + 下单中
|
||||
class PaymentState {
|
||||
const PaymentState({
|
||||
this.result,
|
||||
this.selectedPackageId,
|
||||
this.customYuan,
|
||||
this.selectedMethod,
|
||||
this.isLoading = false,
|
||||
this.loadFailed = false,
|
||||
this.creating = false,
|
||||
});
|
||||
|
||||
final PackagesResult? result;
|
||||
|
||||
/// null = 使用自定义金额
|
||||
final int? selectedPackageId;
|
||||
|
||||
/// 自定义金额(元);null 表示未输入
|
||||
final int? customYuan;
|
||||
final int? selectedMethod;
|
||||
final bool isLoading;
|
||||
final bool loadFailed;
|
||||
final bool creating;
|
||||
|
||||
/// 实际应付金额(元):优先选中档位,否则自定义金额
|
||||
int? get amountYuan {
|
||||
final r = result;
|
||||
if (r == null) return null;
|
||||
final pkgId = selectedPackageId;
|
||||
if (pkgId != null) {
|
||||
for (final p in r.packages) {
|
||||
if (p.id == pkgId) return p.amountYuan;
|
||||
}
|
||||
}
|
||||
return customYuan;
|
||||
}
|
||||
|
||||
PaymentState copyWith({
|
||||
PackagesResult? result,
|
||||
int? Function()? selectedPackageId,
|
||||
int? Function()? customYuan,
|
||||
int? Function()? selectedMethod,
|
||||
bool? isLoading,
|
||||
bool? loadFailed,
|
||||
bool? creating,
|
||||
}) =>
|
||||
PaymentState(
|
||||
result: result ?? this.result,
|
||||
selectedPackageId: selectedPackageId != null
|
||||
? selectedPackageId()
|
||||
: this.selectedPackageId,
|
||||
customYuan: customYuan != null ? customYuan() : this.customYuan,
|
||||
selectedMethod: selectedMethod != null
|
||||
? selectedMethod()
|
||||
: this.selectedMethod,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
loadFailed: loadFailed ?? this.loadFailed,
|
||||
creating: creating ?? this.creating,
|
||||
);
|
||||
}
|
||||
|
||||
class PaymentNotifier extends StateNotifier<PaymentState> {
|
||||
PaymentNotifier() : super(const PaymentState()) {
|
||||
load();
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
state = state.copyWith(isLoading: true, loadFailed: false);
|
||||
try {
|
||||
final result = await PaymentService.fetchPackages();
|
||||
state = state.copyWith(
|
||||
result: result,
|
||||
// 默认选中第一档 + 第一种支付方式
|
||||
selectedPackageId: () =>
|
||||
result.packages.isNotEmpty ? result.packages.first.id : null,
|
||||
customYuan: () => null,
|
||||
selectedMethod: () =>
|
||||
result.payMethods.isNotEmpty ? result.payMethods.first.method : null,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false, loadFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择档位(清空自定义金额)
|
||||
void selectPackage(int id) {
|
||||
state = state.copyWith(selectedPackageId: () => id, customYuan: () => null);
|
||||
}
|
||||
|
||||
/// 输入自定义金额(取消档位选中)
|
||||
void setCustomYuan(int? yuan) {
|
||||
state = state.copyWith(
|
||||
customYuan: () => yuan,
|
||||
selectedPackageId: () => yuan == null ? state.selectedPackageId : null,
|
||||
);
|
||||
}
|
||||
|
||||
void selectMethod(int method) {
|
||||
state = state.copyWith(selectedMethod: () => method);
|
||||
}
|
||||
|
||||
/// 创建充值订单。成功返回订单;失败抛异常由 UI 提示(1001 金额不在档位)。
|
||||
Future<RechargeOrderInfo> createOrder() async {
|
||||
final amount = state.amountYuan;
|
||||
final method = state.selectedMethod;
|
||||
if (amount == null || method == null) {
|
||||
throw ArgumentError('未选择金额或支付方式');
|
||||
}
|
||||
state = state.copyWith(creating: true);
|
||||
try {
|
||||
final order = await PaymentService.createRecharge(
|
||||
amountYuan: amount,
|
||||
payMethod: method,
|
||||
);
|
||||
return order;
|
||||
} finally {
|
||||
if (mounted) state = state.copyWith(creating: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final paymentProvider = StateNotifierProvider<PaymentNotifier, PaymentState>(
|
||||
(_) => PaymentNotifier(),
|
||||
);
|
||||
|
||||
/// 订单轮询状态
|
||||
enum OrderPollStatus { polling, paid, closed, timeout, failed }
|
||||
|
||||
class OrderPollState {
|
||||
const OrderPollState({
|
||||
this.status = OrderPollStatus.polling,
|
||||
required this.order,
|
||||
this.walletPoints,
|
||||
});
|
||||
|
||||
final OrderPollStatus status;
|
||||
final RechargeOrderInfo order;
|
||||
final int? walletPoints;
|
||||
|
||||
OrderPollState copyWith({
|
||||
OrderPollStatus? status,
|
||||
RechargeOrderInfo? order,
|
||||
int? walletPoints,
|
||||
}) =>
|
||||
OrderPollState(
|
||||
status: status ?? this.status,
|
||||
order: order ?? this.order,
|
||||
walletPoints: walletPoints ?? this.walletPoints,
|
||||
);
|
||||
}
|
||||
|
||||
/// 订单轮询:每 2 秒查一次,直至 status != 0 或超过 30 次(《03-接口文档》8.3)
|
||||
class OrderPollNotifier extends StateNotifier<OrderPollState> {
|
||||
OrderPollNotifier(RechargeOrderInfo order)
|
||||
: super(OrderPollState(order: order)) {
|
||||
_start();
|
||||
}
|
||||
|
||||
Timer? _timer;
|
||||
int _count = 0;
|
||||
static const _maxCount = 30;
|
||||
|
||||
void _start() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _poll());
|
||||
}
|
||||
|
||||
Future<void> _poll() async {
|
||||
if (!mounted) return;
|
||||
_count++;
|
||||
try {
|
||||
final info = await PaymentService.fetchOrder(state.order.orderNo);
|
||||
if (!mounted) return;
|
||||
if (info.isPaid) {
|
||||
_finish(OrderPollStatus.paid, walletPoints: info.walletPoints);
|
||||
} else if (!info.isPending) {
|
||||
_finish(OrderPollStatus.closed);
|
||||
} else if (_count >= _maxCount) {
|
||||
_finish(OrderPollStatus.timeout);
|
||||
}
|
||||
} catch (_) {
|
||||
// 网络抖动不立即失败,继续下一轮;累计超限由 _count 兜底
|
||||
if (_count >= _maxCount && mounted) _finish(OrderPollStatus.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
void _finish(OrderPollStatus status, {int? walletPoints}) {
|
||||
_timer?.cancel();
|
||||
state = state.copyWith(status: status, walletPoints: walletPoints);
|
||||
}
|
||||
|
||||
/// 取消订单(仅待支付):成功后结束轮询
|
||||
Future<void> cancel() async {
|
||||
_timer?.cancel();
|
||||
try {
|
||||
await PaymentService.cancelOrder(state.order.orderNo);
|
||||
if (mounted) state = state.copyWith(status: OrderPollStatus.closed);
|
||||
} catch (_) {
|
||||
// 取消失败恢复轮询
|
||||
if (mounted) _start();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 主动停止轮询(离开页面时)
|
||||
void stop() => _timer?.cancel();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final orderPollProvider = StateNotifierProvider.autoDispose
|
||||
.family<OrderPollNotifier, OrderPollState, RechargeOrderInfo>(
|
||||
(ref, order) => OrderPollNotifier(order),
|
||||
);
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/user_profile.dart';
|
||||
import '../models/video_item.dart';
|
||||
import '../services/user_service.dart';
|
||||
|
||||
/// 他人主页状态
|
||||
class ProfileState {
|
||||
const ProfileState({
|
||||
this.profile,
|
||||
this.works = const [],
|
||||
this.worksTotal = 0,
|
||||
this.isLoading = false,
|
||||
this.loadFailed = false,
|
||||
});
|
||||
|
||||
final UserPublicProfile? profile;
|
||||
final List<VideoItem> works;
|
||||
final int worksTotal;
|
||||
final bool isLoading;
|
||||
final bool loadFailed;
|
||||
|
||||
ProfileState copyWith({
|
||||
UserPublicProfile? profile,
|
||||
List<VideoItem>? works,
|
||||
int? worksTotal,
|
||||
bool? isLoading,
|
||||
bool? loadFailed,
|
||||
}) =>
|
||||
ProfileState(
|
||||
profile: profile ?? this.profile,
|
||||
works: works ?? this.works,
|
||||
worksTotal: worksTotal ?? this.worksTotal,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
loadFailed: loadFailed ?? this.loadFailed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 他人主页 Notifier:主页信息 + 已发布作品(一期一次拉 50 条,不做分页加载)
|
||||
class ProfileNotifier extends StateNotifier<ProfileState> {
|
||||
ProfileNotifier(this.userId) : super(const ProfileState()) {
|
||||
load();
|
||||
}
|
||||
|
||||
final int userId;
|
||||
|
||||
Future<void> load() async {
|
||||
state = state.copyWith(isLoading: true, loadFailed: false);
|
||||
try {
|
||||
final profile = await UserService.fetchPublicProfile(userId);
|
||||
final works = await UserService.fetchUserVideos(userId);
|
||||
state = state.copyWith(
|
||||
profile: profile,
|
||||
works: works.items,
|
||||
worksTotal: works.total,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false, loadFailed: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final profileProvider =
|
||||
StateNotifierProvider.family<ProfileNotifier, ProfileState, int>(
|
||||
(ref, userId) => ProfileNotifier(userId),
|
||||
);
|
||||
|
||||
/// 我的作品(需登录,一期一次拉 50 条)
|
||||
final myVideosProvider = FutureProvider.autoDispose<PagedVideos>(
|
||||
(ref) => UserService.fetchMyVideos(),
|
||||
);
|
||||
|
||||
/// 我的收藏(需登录)
|
||||
final myFavoritesProvider = FutureProvider.autoDispose<PagedVideos>(
|
||||
(ref) => UserService.fetchMyFavorites(),
|
||||
);
|
||||
|
||||
/// 观看历史(需登录)
|
||||
final myHistoryProvider = FutureProvider.autoDispose<List<HistoryItem>>(
|
||||
(ref) => UserService.fetchMyHistory(),
|
||||
);
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/storage/search_history_store.dart';
|
||||
import '../models/video_item.dart';
|
||||
import '../services/search_service.dart';
|
||||
|
||||
/// 搜索排序(《03-接口文档》5.10)
|
||||
enum SearchSort {
|
||||
hot('hot', '热度'),
|
||||
latest('latest', '最新');
|
||||
|
||||
const SearchSort(this.apiValue, this.label);
|
||||
|
||||
final String apiValue;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 搜索页状态
|
||||
class SearchState {
|
||||
const SearchState({
|
||||
this.keyword = '',
|
||||
this.sort = SearchSort.hot,
|
||||
this.items = const [],
|
||||
this.total = 0,
|
||||
this.page = 1,
|
||||
this.isSearching = false,
|
||||
this.isLoadingMore = false,
|
||||
this.searched = false,
|
||||
this.hotWords = const [],
|
||||
this.history = const [],
|
||||
});
|
||||
|
||||
final String keyword;
|
||||
final SearchSort sort;
|
||||
final List<VideoItem> items;
|
||||
final int total;
|
||||
final int page;
|
||||
final bool isSearching;
|
||||
final bool isLoadingMore;
|
||||
|
||||
/// 是否已执行过搜索(区分默认页与结果页)
|
||||
final bool searched;
|
||||
|
||||
/// 热搜榜(进入页面时加载)
|
||||
final List<String> hotWords;
|
||||
|
||||
/// 本地搜索历史(最新在前)
|
||||
final List<String> history;
|
||||
|
||||
bool get hasMore => items.length < total;
|
||||
|
||||
SearchState copyWith({
|
||||
String? keyword,
|
||||
SearchSort? sort,
|
||||
List<VideoItem>? items,
|
||||
int? total,
|
||||
int? page,
|
||||
bool? isSearching,
|
||||
bool? isLoadingMore,
|
||||
bool? searched,
|
||||
List<String>? hotWords,
|
||||
List<String>? history,
|
||||
}) =>
|
||||
SearchState(
|
||||
keyword: keyword ?? this.keyword,
|
||||
sort: sort ?? this.sort,
|
||||
items: items ?? this.items,
|
||||
total: total ?? this.total,
|
||||
page: page ?? this.page,
|
||||
isSearching: isSearching ?? this.isSearching,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
searched: searched ?? this.searched,
|
||||
hotWords: hotWords ?? this.hotWords,
|
||||
history: history ?? this.history,
|
||||
);
|
||||
}
|
||||
|
||||
class SearchNotifier extends StateNotifier<SearchState> {
|
||||
SearchNotifier() : super(SearchState(history: SearchHistoryStore.I.history));
|
||||
|
||||
/// 加载热搜榜(静默失败,空列表兜底)
|
||||
Future<void> loadHot() async {
|
||||
try {
|
||||
final words = await SearchService.hotWords();
|
||||
state = state.copyWith(hotWords: words);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 执行搜索(换关键词或排序时全量重来)
|
||||
Future<void> search(String keyword, {SearchSort? sort}) async {
|
||||
final word = keyword.trim();
|
||||
if (word.isEmpty) return;
|
||||
final nextSort = sort ?? state.sort;
|
||||
state = state.copyWith(
|
||||
keyword: word,
|
||||
sort: nextSort,
|
||||
isSearching: true,
|
||||
searched: true,
|
||||
items: const [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
);
|
||||
// 写入本地历史(去重置顶,最多 10 条)
|
||||
await SearchHistoryStore.I.add(word);
|
||||
state = state.copyWith(history: SearchHistoryStore.I.history);
|
||||
try {
|
||||
final page =
|
||||
await SearchService.search(word, sort: nextSort.apiValue);
|
||||
state = state.copyWith(
|
||||
items: page.items,
|
||||
total: page.total,
|
||||
isSearching: false,
|
||||
page: 1,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isSearching: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 触底加载更多
|
||||
Future<void> loadMore() async {
|
||||
if (!state.hasMore || state.isLoadingMore || state.isSearching) return;
|
||||
state = state.copyWith(isLoadingMore: true);
|
||||
try {
|
||||
final page = await SearchService.search(
|
||||
state.keyword,
|
||||
sort: state.sort.apiValue,
|
||||
page: state.page + 1,
|
||||
);
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
total: page.total,
|
||||
isLoadingMore: false,
|
||||
page: state.page + 1,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoadingMore: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除单条历史
|
||||
Future<void> removeHistory(String word) async {
|
||||
await SearchHistoryStore.I.remove(word);
|
||||
state = state.copyWith(history: SearchHistoryStore.I.history);
|
||||
}
|
||||
|
||||
/// 清空历史
|
||||
Future<void> clearHistory() async {
|
||||
await SearchHistoryStore.I.clear();
|
||||
state = state.copyWith(history: const []);
|
||||
}
|
||||
}
|
||||
|
||||
final searchProvider =
|
||||
StateNotifierProvider<SearchNotifier, SearchState>((ref) => SearchNotifier());
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/video_item.dart';
|
||||
import '../services/video_service.dart';
|
||||
import 'search_provider.dart';
|
||||
|
||||
/// 标签视频页状态(《03-接口文档》5.9,游客可)
|
||||
class TagVideosState {
|
||||
const TagVideosState({
|
||||
this.sort = SearchSort.hot,
|
||||
this.items = const [],
|
||||
this.total = 0,
|
||||
this.page = 1,
|
||||
this.isLoading = false,
|
||||
this.isLoadingMore = false,
|
||||
this.loaded = false,
|
||||
this.loadFailed = false,
|
||||
});
|
||||
|
||||
final SearchSort sort;
|
||||
final List<VideoItem> items;
|
||||
final int total;
|
||||
final int page;
|
||||
final bool isLoading;
|
||||
final bool isLoadingMore;
|
||||
|
||||
/// 是否已完成首次加载(区分加载中与空结果)
|
||||
final bool loaded;
|
||||
|
||||
/// 首次加载失败(展示重试入口)
|
||||
final bool loadFailed;
|
||||
|
||||
bool get hasMore => items.length < total;
|
||||
|
||||
TagVideosState copyWith({
|
||||
SearchSort? sort,
|
||||
List<VideoItem>? items,
|
||||
int? total,
|
||||
int? page,
|
||||
bool? isLoading,
|
||||
bool? isLoadingMore,
|
||||
bool? loaded,
|
||||
bool? loadFailed,
|
||||
}) =>
|
||||
TagVideosState(
|
||||
sort: sort ?? this.sort,
|
||||
items: items ?? this.items,
|
||||
total: total ?? this.total,
|
||||
page: page ?? this.page,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||
loaded: loaded ?? this.loaded,
|
||||
loadFailed: loadFailed ?? this.loadFailed,
|
||||
);
|
||||
}
|
||||
|
||||
class TagVideosNotifier extends StateNotifier<TagVideosState> {
|
||||
TagVideosNotifier(this.tagName) : super(const TagVideosState()) {
|
||||
load(sort: SearchSort.hot);
|
||||
}
|
||||
|
||||
final String tagName;
|
||||
|
||||
/// 首次加载 / 切换排序全量重来
|
||||
Future<void> load({SearchSort? sort}) async {
|
||||
final nextSort = sort ?? state.sort;
|
||||
state = state.copyWith(
|
||||
sort: nextSort,
|
||||
isLoading: true,
|
||||
loadFailed: false,
|
||||
items: const [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
);
|
||||
try {
|
||||
final page = await VideoService.fetchTagVideos(
|
||||
tagName,
|
||||
sort: nextSort.apiValue,
|
||||
);
|
||||
state = state.copyWith(
|
||||
items: page.items,
|
||||
total: page.total,
|
||||
isLoading: false,
|
||||
loaded: true,
|
||||
page: 1,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false, loaded: true, loadFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 触底加载更多
|
||||
Future<void> loadMore() async {
|
||||
if (!state.hasMore || state.isLoadingMore || state.isLoading) return;
|
||||
state = state.copyWith(isLoadingMore: true);
|
||||
try {
|
||||
final page = await VideoService.fetchTagVideos(
|
||||
tagName,
|
||||
sort: state.sort.apiValue,
|
||||
page: state.page + 1,
|
||||
);
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
total: page.total,
|
||||
isLoadingMore: false,
|
||||
page: state.page + 1,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoadingMore: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按标签名分族(进入页面自动加载)
|
||||
final tagVideosProvider = StateNotifierProvider.autoDispose
|
||||
.family<TagVideosNotifier, TagVideosState, String>(
|
||||
(ref, tagName) => TagVideosNotifier(tagName),
|
||||
);
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../services/upload_service.dart';
|
||||
|
||||
/// 上传发布流程步骤:选视频 → 填信息 → 发布中 → 完成
|
||||
enum UploadStep { pick, edit, publishing, done }
|
||||
|
||||
/// 上传发布状态(《03-接口文档》5.5/5.6 三步链路)
|
||||
class UploadState {
|
||||
const UploadState({
|
||||
this.step = UploadStep.pick,
|
||||
this.file,
|
||||
this.fileSize = 0,
|
||||
this.progress = 0,
|
||||
this.result,
|
||||
this.error = '',
|
||||
});
|
||||
|
||||
final UploadStep step;
|
||||
final XFile? file; // 已选择的视频
|
||||
final int fileSize; // 字节
|
||||
final int progress; // 上传进度 0-100
|
||||
final PublishResult? result;
|
||||
final String error; // 发布失败文案(停留在 edit 步骤展示)
|
||||
|
||||
bool get publishing => step == UploadStep.publishing;
|
||||
|
||||
UploadState copyWith({
|
||||
UploadStep? step,
|
||||
XFile? file,
|
||||
int? fileSize,
|
||||
int? progress,
|
||||
PublishResult? result,
|
||||
String? error,
|
||||
}) =>
|
||||
UploadState(
|
||||
step: step ?? this.step,
|
||||
file: file ?? this.file,
|
||||
fileSize: fileSize ?? this.fileSize,
|
||||
progress: progress ?? this.progress,
|
||||
result: result ?? this.result,
|
||||
error: error ?? this.error,
|
||||
);
|
||||
}
|
||||
|
||||
class UploadNotifier extends StateNotifier<UploadState> {
|
||||
UploadNotifier() : super(const UploadState());
|
||||
|
||||
final _picker = ImagePicker();
|
||||
|
||||
/// 从相册选择视频 → 进入填信息步骤
|
||||
Future<void> pickVideo() async {
|
||||
final file = await _picker.pickVideo(source: ImageSource.gallery);
|
||||
if (file == null) return;
|
||||
final size = await file.length();
|
||||
state = UploadState(step: UploadStep.edit, file: file, fileSize: size);
|
||||
}
|
||||
|
||||
/// 重新选择(回到选视频步骤)
|
||||
void reset() => state = const UploadState();
|
||||
|
||||
/// 发布:获取凭证 → 上传文件(进度) → 创建视频
|
||||
Future<void> submit({
|
||||
required String title,
|
||||
String description = '',
|
||||
required int categoryId,
|
||||
List<String> tags = const [],
|
||||
}) async {
|
||||
final file = state.file;
|
||||
if (file == null || state.publishing) return;
|
||||
state = state.copyWith(step: UploadStep.publishing, progress: 0, error: '');
|
||||
try {
|
||||
final token = await UploadService.fetchUploadToken(
|
||||
fileName: file.name,
|
||||
fileSize: state.fileSize,
|
||||
);
|
||||
await UploadService.uploadFile(
|
||||
objectKey: token.objectKey,
|
||||
filePath: file.path,
|
||||
fileName: file.name,
|
||||
onProgress: (sent, total) {
|
||||
if (total > 0) {
|
||||
state = state.copyWith(progress: (sent * 100) ~/ total);
|
||||
}
|
||||
},
|
||||
);
|
||||
final result = await UploadService.publish(
|
||||
objectKey: token.objectKey,
|
||||
title: title,
|
||||
description: description,
|
||||
categoryId: categoryId,
|
||||
tags: tags,
|
||||
);
|
||||
state = state.copyWith(step: UploadStep.done, result: result);
|
||||
} on ApiException catch (e) {
|
||||
// 失败回到表单步骤,保留已填内容,展示服务端文案
|
||||
state = state.copyWith(step: UploadStep.edit, error: e.message);
|
||||
} catch (_) {
|
||||
state = state.copyWith(step: UploadStep.edit, error: '网络异常,请稍后重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传发布(autoDispose:离开页面即重置流程)
|
||||
final uploadProvider =
|
||||
StateNotifierProvider.autoDispose<UploadNotifier, UploadState>(
|
||||
(ref) => UploadNotifier(),
|
||||
);
|
||||
|
||||
/// 分类列表(发布表单用)
|
||||
final uploadCategoriesProvider =
|
||||
FutureProvider.autoDispose<List<VideoCategory>>(
|
||||
(ref) => UploadService.fetchCategories(),
|
||||
);
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/wallet_service.dart';
|
||||
|
||||
/// 钱包状态:钱包信息 + 积分明细(biz_type 筛选 + 触底分页)
|
||||
class WalletState {
|
||||
const WalletState({
|
||||
this.wallet,
|
||||
this.records = const [],
|
||||
this.recordsTotal = 0,
|
||||
this.bizType,
|
||||
this.isLoading = false,
|
||||
this.loadFailed = false,
|
||||
this.loadingMore = false,
|
||||
});
|
||||
|
||||
final WalletInfo? wallet;
|
||||
final List<PointsRecord> records;
|
||||
final int recordsTotal;
|
||||
|
||||
/// null = 全部
|
||||
final int? bizType;
|
||||
final bool isLoading;
|
||||
final bool loadFailed;
|
||||
final bool loadingMore;
|
||||
|
||||
bool get hasMore => records.length < recordsTotal;
|
||||
|
||||
WalletState copyWith({
|
||||
WalletInfo? wallet,
|
||||
List<PointsRecord>? records,
|
||||
int? recordsTotal,
|
||||
int? Function()? bizType,
|
||||
bool? isLoading,
|
||||
bool? loadFailed,
|
||||
bool? loadingMore,
|
||||
}) =>
|
||||
WalletState(
|
||||
wallet: wallet ?? this.wallet,
|
||||
records: records ?? this.records,
|
||||
recordsTotal: recordsTotal ?? this.recordsTotal,
|
||||
bizType: bizType != null ? bizType() : this.bizType,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
loadFailed: loadFailed ?? this.loadFailed,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
);
|
||||
}
|
||||
|
||||
class WalletNotifier extends StateNotifier<WalletState> {
|
||||
WalletNotifier() : super(const WalletState()) {
|
||||
load();
|
||||
}
|
||||
|
||||
static const _pageSize = 20;
|
||||
|
||||
Future<void> load() async {
|
||||
state = state.copyWith(isLoading: true, loadFailed: false);
|
||||
try {
|
||||
final wallet = await WalletService.fetchWallet();
|
||||
final page = await WalletService.fetchPointsRecords(
|
||||
page: 1,
|
||||
pageSize: _pageSize,
|
||||
bizType: state.bizType,
|
||||
);
|
||||
state = state.copyWith(
|
||||
wallet: wallet,
|
||||
records: page.items,
|
||||
recordsTotal: page.total,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false, loadFailed: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换 biz_type 筛选并重载明细
|
||||
Future<void> setBizType(int? bizType) async {
|
||||
if (bizType == state.bizType) return;
|
||||
state = state.copyWith(bizType: () => bizType);
|
||||
await load();
|
||||
}
|
||||
|
||||
/// 触底加载下一页
|
||||
Future<void> loadMore() async {
|
||||
if (state.loadingMore || !state.hasMore || state.isLoading) return;
|
||||
state = state.copyWith(loadingMore: true);
|
||||
try {
|
||||
final page = await WalletService.fetchPointsRecords(
|
||||
page: state.records.length ~/ _pageSize + 1,
|
||||
pageSize: _pageSize,
|
||||
bizType: state.bizType,
|
||||
);
|
||||
state = state.copyWith(
|
||||
records: [...state.records, ...page.items],
|
||||
recordsTotal: page.total,
|
||||
loadingMore: false,
|
||||
);
|
||||
} catch (_) {
|
||||
state = state.copyWith(loadingMore: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final walletProvider = StateNotifierProvider<WalletNotifier, WalletState>(
|
||||
(_) => WalletNotifier(),
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../models/video_item.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../screens/assets/assets_screen.dart';
|
||||
import '../screens/assets/withdraw_screen.dart';
|
||||
import '../screens/auth/login_screen.dart';
|
||||
import '../screens/auth/register_screen.dart';
|
||||
import '../screens/home/home_shell.dart';
|
||||
import '../screens/membership/membership_screen.dart';
|
||||
import '../screens/notification/notifications_screen.dart';
|
||||
import '../screens/payment/recharge_screen.dart';
|
||||
import '../screens/payment/usdt_pay_screen.dart';
|
||||
import '../screens/player/simple_player_screen.dart';
|
||||
import '../screens/profile/edit_video_screen.dart';
|
||||
import '../screens/profile/invite_screen.dart';
|
||||
import '../screens/profile/profile_edit_screen.dart';
|
||||
import '../screens/profile/user_profile_screen.dart';
|
||||
import '../screens/profile/video_list_screens.dart';
|
||||
import '../screens/search/search_screen.dart';
|
||||
import '../screens/search/tag_videos_screen.dart';
|
||||
import '../screens/settings/settings_screen.dart';
|
||||
import '../screens/splash/splash_screen.dart';
|
||||
import '../screens/upload/upload_screen.dart';
|
||||
import '../screens/wallet/wallet_screen.dart';
|
||||
import '../services/payment_service.dart';
|
||||
|
||||
/// 声明式路由(02-技术文档:go_router)
|
||||
/// /home 对游客开放(游客可浏览 Feed,观看超限 2002 时才引导登录);
|
||||
/// 受保护页面(上传/钱包/会员等)在各自页面入口校验登录态并跳 /login。
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/',
|
||||
redirect: (context, state) {
|
||||
final loggingIn = state.matchedLocation == '/login' ||
|
||||
state.matchedLocation == '/register';
|
||||
// 启动恢复未完成:停在 splash
|
||||
if (!auth.restored && state.matchedLocation != '/') return '/';
|
||||
// 已登录去登录页 → 回首页
|
||||
if (auth.isLoggedIn && loggingIn) return '/home';
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, __) => const SplashScreen()),
|
||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
||||
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeShell()),
|
||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||
// 标签视频页(游客可)
|
||||
GoRoute(
|
||||
path: '/tags/:name',
|
||||
builder: (_, state) =>
|
||||
TagVideosScreen(tagName: state.pathParameters['name']!),
|
||||
),
|
||||
// 搜索结果单视频播放(extra 传 VideoItem)
|
||||
GoRoute(
|
||||
path: '/search/play',
|
||||
builder: (_, state) =>
|
||||
SimplePlayerScreen(item: state.extra! as VideoItem),
|
||||
),
|
||||
// 他人公开主页(游客可)
|
||||
GoRoute(
|
||||
path: '/user/:id',
|
||||
builder: (_, state) =>
|
||||
UserProfileScreen(userId: int.parse(state.pathParameters['id']!)),
|
||||
),
|
||||
// 我的:作品/收藏/历史(页面内需登录,未登录由入口守卫)
|
||||
GoRoute(path: '/mine/works', builder: (_, __) => VideoGridScreen.works()),
|
||||
// 编辑作品(extra 传 VideoItem,仅作者)
|
||||
GoRoute(
|
||||
path: '/mine/works/edit',
|
||||
builder: (_, state) =>
|
||||
EditVideoScreen(video: state.extra! as VideoItem),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/mine/favorites',
|
||||
builder: (_, __) => VideoGridScreen.favorites(),
|
||||
),
|
||||
GoRoute(path: '/mine/history', builder: (_, __) => const HistoryScreen()),
|
||||
// 编辑资料 / 头像(页面内兜底未登录引导)
|
||||
GoRoute(
|
||||
path: '/profile/edit',
|
||||
builder: (_, __) => const ProfileEditScreen(),
|
||||
),
|
||||
// 钱包 / 会员中心(页面内兜底未登录引导)
|
||||
GoRoute(path: '/wallet', builder: (_, __) => const WalletScreen()),
|
||||
GoRoute(path: '/membership', builder: (_, __) => const MembershipScreen()),
|
||||
// 消息通知(页面内兜底未登录引导)
|
||||
GoRoute(
|
||||
path: '/notifications',
|
||||
builder: (_, __) => const NotificationsScreen(),
|
||||
),
|
||||
// 发布作品(页面内兜底未登录引导)
|
||||
GoRoute(path: '/upload', builder: (_, __) => const UploadScreen()),
|
||||
// 邀请好友(页面内需登录)
|
||||
GoRoute(path: '/invite', builder: (_, __) => const InviteScreen()),
|
||||
// 收益资产 / 提现(页面内兜底未登录引导)
|
||||
GoRoute(path: '/assets', builder: (_, __) => const AssetsScreen()),
|
||||
GoRoute(
|
||||
path: '/assets/withdraw',
|
||||
builder: (_, __) => const WithdrawScreen(),
|
||||
),
|
||||
// 充值 / USDT 支付(需登录,页面内兜底)
|
||||
GoRoute(path: '/recharge', builder: (_, __) => const RechargeScreen()),
|
||||
GoRoute(
|
||||
path: '/recharge/usdt',
|
||||
builder: (_, state) =>
|
||||
UsdtPayScreen(order: state.extra! as RechargeOrderInfo),
|
||||
),
|
||||
// 设置
|
||||
GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
/// 兜底页面(迭代期占位)
|
||||
class PlaceholderScreen extends StatelessWidget {
|
||||
const PlaceholderScreen({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(child: Text(title, style: const TextStyle(fontSize: 16))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/asset_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../services/asset_service.dart';
|
||||
|
||||
/// 资产数字展示:整数不带小数点,否则保留两位
|
||||
String fmtAssets(double v) =>
|
||||
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(2);
|
||||
|
||||
/// 收益资产页(《03-接口文档》第 9 章):
|
||||
/// 资产余额(10 资产 = 1 元)/ 累计收益·提现 / 收益明细(筛选 + 触底分页)/ 提现入口
|
||||
class AssetsScreen extends ConsumerWidget {
|
||||
const AssetsScreen({super.key});
|
||||
|
||||
static const List<Map<String, dynamic>> _filters = [
|
||||
{'label': '全部', 'value': null},
|
||||
{'label': '充值返利', 'value': 1},
|
||||
{'label': '观看收益', 'value': 2},
|
||||
{'label': '点赞收益', 'value': 3},
|
||||
{'label': '提现', 'value': 4},
|
||||
{'label': '提现退回', 'value': 5},
|
||||
{'label': '返利扣回', 'value': 6},
|
||||
{'label': '后台调整', 'value': 7},
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isLoggedIn = ref.watch(authProvider).isLoggedIn;
|
||||
if (!isLoggedIn) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('收益资产')),
|
||||
body: _GuestGuide(onLogin: () => context.push('/login')),
|
||||
);
|
||||
}
|
||||
|
||||
final state = ref.watch(assetProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('收益资产')),
|
||||
body: state.isLoading && state.wallet == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: state.loadFailed && state.wallet == null
|
||||
? _LoadFailed(
|
||||
onRetry: () => ref.read(assetProvider.notifier).load(),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => ref.read(assetProvider.notifier).load(),
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.pixels >= n.metrics.maxScrollExtent - 200) {
|
||||
ref.read(assetProvider.notifier).loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_BalanceCard(wallet: state.wallet),
|
||||
const SizedBox(height: 12),
|
||||
// 提现入口
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => context
|
||||
.push('/assets/withdraw')
|
||||
.then((_) =>
|
||||
ref.read(assetProvider.notifier).load()),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 44),
|
||||
),
|
||||
child: const Text('申请提现'),
|
||||
),
|
||||
),
|
||||
if (state.wallet?.hasPendingWithdraw ?? false) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'有一笔提现审核中,审核完成后可再次申请',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppColors.textWeak,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
// 明细筛选
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _filters.map((f) {
|
||||
final active = state.bizType == f['value'];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(f['label'] as String),
|
||||
selected: active,
|
||||
onSelected: (_) => ref
|
||||
.read(assetProvider.notifier)
|
||||
.setBizType(f['value'] as int?),
|
||||
selectedColor: AppColors.primary,
|
||||
backgroundColor: AppColors.surface,
|
||||
labelStyle: TextStyle(
|
||||
color: active
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (state.records.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(
|
||||
child: Text('暂无收益明细',
|
||||
style:
|
||||
TextStyle(color: AppColors.textWeak)),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
for (final r in state.records) _RecordTile(record: r),
|
||||
if (state.hasMore)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 资产卡:余额 + 折合 + 累计
|
||||
class _BalanceCard extends StatelessWidget {
|
||||
const _BalanceCard({required this.wallet});
|
||||
|
||||
final AssetWallet? wallet;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final w = wallet;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.goldGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'资产余额',
|
||||
style: TextStyle(color: Colors.black54, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
fmtAssets(w?.assets ?? 0),
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'约 ¥${(w?.assetsValueYuan ?? 0).toStringAsFixed(2)}(10 资产 = 1 元)',
|
||||
style: const TextStyle(color: Colors.black54, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Stat(label: '累计收益', value: fmtAssets(w?.totalEarned ?? 0)),
|
||||
),
|
||||
Expanded(
|
||||
child:
|
||||
_Stat(label: '累计提现', value: fmtAssets(w?.totalWithdrawn ?? 0)),
|
||||
),
|
||||
Expanded(
|
||||
child: _Stat(
|
||||
label: '提现门槛',
|
||||
value: '${w?.minWithdrawAssets ?? 100} 资产',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Stat extends StatelessWidget {
|
||||
const _Stat({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(color: Colors.black54, fontSize: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecordTile extends StatelessWidget {
|
||||
const _RecordTile({required this.record});
|
||||
|
||||
final AssetRecord record;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isExpense = record.isExpense;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(record.bizTypeLabel, style: const TextStyle(fontSize: 15)),
|
||||
if (record.remark.isNotEmpty)
|
||||
Text(
|
||||
record.remark,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textWeak,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
record.createdAt,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textWeak,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${isExpense ? '' : '+'}${fmtAssets(record.changeAmount)}',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isExpense ? Colors.white : AppColors.gold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'余额 ${fmtAssets(record.balanceAfter)}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textWeak,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GuestGuide extends StatelessWidget {
|
||||
const _GuestGuide({required this.onLogin});
|
||||
|
||||
final VoidCallback onLogin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.savings_outlined,
|
||||
size: 64, color: AppColors.textWeak),
|
||||
const SizedBox(height: 12),
|
||||
const Text('登录后查看收益资产',
|
||||
style: TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: onLogin,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(160, 44),
|
||||
),
|
||||
child: const Text('去登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadFailed extends StatelessWidget {
|
||||
const _LoadFailed({required this.onRetry});
|
||||
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('加载失败', style: TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(onPressed: onRetry, child: const Text('点击重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/asset_provider.dart';
|
||||
import '../../services/asset_service.dart';
|
||||
import '../feed/widgets/feed_overlays.dart';
|
||||
import 'assets_screen.dart';
|
||||
|
||||
/// 提现页(《03-接口文档》9.5/9.6):提现表单 + 提现记录
|
||||
class WithdrawScreen extends ConsumerStatefulWidget {
|
||||
const WithdrawScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WithdrawScreen> createState() => _WithdrawScreenState();
|
||||
}
|
||||
|
||||
class _WithdrawScreenState extends ConsumerState<WithdrawScreen> {
|
||||
final _amountCtrl = TextEditingController();
|
||||
final _accountCtrl = TextEditingController();
|
||||
int _channel = 1;
|
||||
bool _submitting = false;
|
||||
|
||||
static const _channels = [
|
||||
{'value': 1, 'label': '支付宝', 'hint': '支付宝账号(手机号/邮箱)'},
|
||||
{'value': 2, 'label': '微信', 'hint': '微信收款账号'},
|
||||
{'value': 3, 'label': 'USDT', 'hint': 'USDT 收款地址(TRC20)'},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountCtrl.dispose();
|
||||
_accountCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Map<String, Object> get _channelCfg =>
|
||||
_channels.firstWhere((c) => c['value'] == _channel);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(assetProvider);
|
||||
final records = ref.watch(withdrawRecordsProvider);
|
||||
final wallet = asset.wallet;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('申请提现')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildForm(wallet),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'提现记录',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (records.isLoading && records.items.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (records.items.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
child: Text('暂无提现记录',
|
||||
style: TextStyle(color: AppColors.textWeak)),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
for (final r in records.items) _WithdrawTile(record: r),
|
||||
if (records.hasMore)
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
ref.read(withdrawRecordsProvider.notifier).loadMore(),
|
||||
child: const Text('加载更多'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm(AssetWallet? wallet) {
|
||||
final hasPending = wallet?.hasPendingWithdraw ?? false;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'可提资产 ${fmtAssets(wallet?.assets ?? 0)}',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'最低 ${wallet?.minWithdrawAssets ?? 100} 资产',
|
||||
style:
|
||||
const TextStyle(color: AppColors.textWeak, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 金额
|
||||
TextField(
|
||||
controller: _amountCtrl,
|
||||
enabled: !hasPending,
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
labelText: '提现金额(资产)',
|
||||
suffix: TextButton(
|
||||
onPressed: hasPending || wallet == null
|
||||
? null
|
||||
: () => _amountCtrl.text = fmtAssets(wallet.assets),
|
||||
child: const Text('全部提现'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 渠道
|
||||
Row(
|
||||
children: _channels.map((c) {
|
||||
final active = _channel == c['value'];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(c['label'] as String),
|
||||
selected: active,
|
||||
onSelected: hasPending
|
||||
? null
|
||||
: (_) => setState(() => _channel = c['value'] as int),
|
||||
selectedColor: AppColors.primary,
|
||||
backgroundColor: AppColors.surface,
|
||||
labelStyle: TextStyle(
|
||||
color: active ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 账号
|
||||
TextField(
|
||||
controller: _accountCtrl,
|
||||
enabled: !hasPending,
|
||||
decoration: InputDecoration(
|
||||
labelText: '收款账号',
|
||||
hintText: _channelCfg['hint'] as String,
|
||||
),
|
||||
),
|
||||
if (hasPending) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'有一笔提现审核中,审核完成后才能再次申请',
|
||||
style: TextStyle(color: AppColors.gold, fontSize: 12),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed:
|
||||
_submitting || hasPending ? null : () => _submit(wallet),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 44),
|
||||
),
|
||||
child: Text(_submitting ? '提交中…' : '提交申请'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit(AssetWallet? wallet) async {
|
||||
final amount = double.tryParse(_amountCtrl.text.trim());
|
||||
final account = _accountCtrl.text.trim();
|
||||
final minAssets = wallet?.minWithdrawAssets ?? 100;
|
||||
if (amount == null || amount <= 0) {
|
||||
showTopToast(context, '请输入提现金额');
|
||||
return;
|
||||
}
|
||||
if (amount < minAssets) {
|
||||
showTopToast(context, '最低提现 $minAssets 资产');
|
||||
return;
|
||||
}
|
||||
if (wallet != null && amount > wallet.assets) {
|
||||
showTopToast(context, '超出可提资产余额');
|
||||
return;
|
||||
}
|
||||
if (account.isEmpty) {
|
||||
showTopToast(context, '请输入收款账号');
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final result = await AssetService.applyWithdraw(
|
||||
assets: amount,
|
||||
channel: _channel,
|
||||
account: account,
|
||||
);
|
||||
if (!mounted) return;
|
||||
showTopToast(
|
||||
context,
|
||||
'提现申请已提交:¥${result.amountYuan.toStringAsFixed(2)},等待审核',
|
||||
);
|
||||
_amountCtrl.clear();
|
||||
// 刷新资产与记录
|
||||
ref.read(assetProvider.notifier).load();
|
||||
ref.read(withdrawRecordsProvider.notifier).load();
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showTopToast(context, e.message);
|
||||
} catch (_) {
|
||||
if (mounted) showTopToast(context, '提交失败,请稍后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _WithdrawTile extends StatelessWidget {
|
||||
const _WithdrawTile({required this.record});
|
||||
|
||||
final WithdrawRecord record;
|
||||
|
||||
Color get _statusColor => switch (record.status) {
|
||||
1 => Colors.greenAccent,
|
||||
2 => AppColors.primary,
|
||||
_ => AppColors.gold,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'¥${record.amountYuan.toStringAsFixed(2)}(${fmtAssets(record.assets)} 资产)',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
Text(
|
||||
'${record.channelLabel} · ${record.withdrawNo}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textWeak,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
record.createdAt,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textWeak,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
record.statusText,
|
||||
style: TextStyle(color: _statusColor, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
|
||||
/// 登录页:骨架期提供手机号+密码登录,验证码/邮箱登录随迭代补充
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _phone = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _submit() async {
|
||||
final phone = _phone.text.trim();
|
||||
final password = _password.text;
|
||||
if (phone.isEmpty || password.isEmpty) {
|
||||
setState(() => _error = '请输入手机号和密码');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final result = await AuthService.login(
|
||||
type: 'phone_password',
|
||||
phone: phone,
|
||||
password: password,
|
||||
);
|
||||
await ref.read(authProvider.notifier).onLoginSuccess(result.user);
|
||||
if (mounted) context.go('/home');
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString().replaceFirst('ApiException(', '').split(', ').last);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('登录')),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
const Text(
|
||||
'欢迎回来',
|
||||
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(hintText: '手机号'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(hintText: '密码'),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: const TextStyle(color: AppColors.primary, fontSize: 13)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('登录'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () => context.push('/register'),
|
||||
child: const Text(
|
||||
'没有账号?去注册',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/storage/local_store.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
|
||||
/// 注册页:手机号 + 验证码 + 密码;邀请码选填(口令/模糊归因命中时自动预填)
|
||||
class RegisterScreen extends ConsumerStatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
final _phone = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
final _nickname = TextEditingController();
|
||||
late final TextEditingController _inviteCode;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
/// 验证码发送倒计时
|
||||
int _countdown = 0;
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 首启归因命中的邀请码预填(用户可改可清空)
|
||||
_inviteCode = TextEditingController(text: LocalStore.I.pendingInviteCode ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_phone.dispose();
|
||||
_code.dispose();
|
||||
_password.dispose();
|
||||
_nickname.dispose();
|
||||
_inviteCode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendCode() async {
|
||||
final phone = _phone.text.trim();
|
||||
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) {
|
||||
setState(() => _error = '请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await AuthService.sendSmsCode(phone, 'register');
|
||||
setState(() {
|
||||
_error = null;
|
||||
_countdown = 60;
|
||||
});
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (_countdown <= 1) {
|
||||
t.cancel();
|
||||
setState(() => _countdown = 0);
|
||||
} else {
|
||||
setState(() => _countdown -= 1);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _error = _errText(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final phone = _phone.text.trim();
|
||||
final code = _code.text.trim();
|
||||
final password = _password.text;
|
||||
if (phone.isEmpty || code.isEmpty || password.isEmpty) {
|
||||
setState(() => _error = '请填写手机号、验证码和密码');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final result = await AuthService.register(
|
||||
type: 'phone',
|
||||
account: phone,
|
||||
code: code,
|
||||
password: password,
|
||||
nickname: _nickname.text.trim(),
|
||||
inviteCode: _inviteCode.text.trim().toUpperCase(),
|
||||
);
|
||||
// 注册成功:pending 邀请码已被消费,清除本地标记
|
||||
await LocalStore.I.clearPendingInviteCode();
|
||||
await ref.read(authProvider.notifier).onLoginSuccess(result.user);
|
||||
if (!mounted) return;
|
||||
if (result.trialPlanName != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('注册成功,已赠送${result.trialPlanName}')),
|
||||
);
|
||||
}
|
||||
context.go('/home');
|
||||
} catch (e) {
|
||||
setState(() => _error = _errText(e));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _errText(Object e) =>
|
||||
e.toString().replaceFirst('ApiException(', '').split(', ').last.replaceAll(')', '');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('注册')),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
const Text('创建账号', style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(hintText: '手机号'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _code,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(hintText: '短信验证码'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: OutlinedButton(
|
||||
onPressed: _countdown > 0 ? null : _sendCode,
|
||||
child: Text(_countdown > 0 ? '${_countdown}s 后重发' : '获取验证码'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(hintText: '密码(8-32 位,含字母和数字)'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _nickname,
|
||||
decoration: const InputDecoration(hintText: '昵称(选填)'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _inviteCode,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: const InputDecoration(hintText: '邀请码(选填)'),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: const TextStyle(color: AppColors.primary, fontSize: 13)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('注册并登录'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('已有账号?去登录', style: TextStyle(color: AppColors.textSecondary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/utils/share_text.dart';
|
||||
import '../../models/video_item.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import 'widgets/comment_sheet.dart';
|
||||
import 'widgets/feed_overlays.dart';
|
||||
import 'widgets/feed_video_page.dart';
|
||||
|
||||
/// 首页 Feed:全屏竖向 PageView 沉浸式播放(《01-开发文档》5.2.1)
|
||||
/// - 顶部:策略 Tab(推荐/最新/热度)+ 搜索图标(占位)
|
||||
/// - 底部:游客/非会员免费剩余次数提示条
|
||||
/// - 滑出暂停、滑入自动播放并上报观看计费
|
||||
class FeedScreen extends ConsumerStatefulWidget {
|
||||
const FeedScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<FeedScreen> createState() => _FeedScreenState();
|
||||
}
|
||||
|
||||
class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
with WidgetsBindingObserver {
|
||||
FeedStrategy _strategy = FeedStrategy.recommend;
|
||||
final PageController _pageController = PageController();
|
||||
StreamSubscription<FeedEvent>? _eventSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动)
|
||||
_eventSub = ref
|
||||
.read(feedProvider(_strategy).notifier)
|
||||
.events
|
||||
.listen(_handleEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_eventSub?.cancel();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// App 退后台暂停、回前台恢复播放
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive) {
|
||||
notifier.pool.pauseCurrent();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
notifier.pool.playCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEvent(FeedEvent event) {
|
||||
if (!mounted) return;
|
||||
switch (event) {
|
||||
case FeedToastEvent(:final message):
|
||||
showTopToast(context, message);
|
||||
case FeedGuestLimitEvent():
|
||||
showGuestLimitDialog(context);
|
||||
case FeedPointsInsufficientEvent(:final points):
|
||||
showPointsInsufficientDialog(context, points);
|
||||
}
|
||||
}
|
||||
|
||||
void _switchStrategy(FeedStrategy next) {
|
||||
if (next == _strategy) return;
|
||||
// 暂停旧策略播放,切换后新策略独立维护状态与播放器池
|
||||
ref.read(feedProvider(_strategy).notifier).pool.pauseCurrent();
|
||||
setState(() => _strategy = next);
|
||||
_eventSub?.cancel();
|
||||
_eventSub = ref
|
||||
.read(feedProvider(_strategy).notifier)
|
||||
.events
|
||||
.listen(_handleEvent);
|
||||
_pageController.jumpToPage(0);
|
||||
}
|
||||
|
||||
/// 打开评论弹层:打开期间暂停当前播放,关闭后恢复
|
||||
Future<void> _openComments(VideoItem item) async {
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
notifier.pool.pauseCurrent();
|
||||
await showCommentSheet(
|
||||
context,
|
||||
videoId: item.videoId,
|
||||
onCountChanged: (delta) =>
|
||||
notifier.adjustCommentCount(item.videoId, delta),
|
||||
);
|
||||
if (mounted) notifier.pool.playCurrent();
|
||||
}
|
||||
|
||||
/// 系统分享面板(分享文案:标题 + 标签 + 播放链接)
|
||||
void _share(VideoItem item) => Share.share(buildShareText(item));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(feedProvider(_strategy));
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
// 内容区
|
||||
if (state.isRefreshing && state.items.isEmpty)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
)
|
||||
else if (state.loadFailed && state.items.isEmpty)
|
||||
_buildRetry(notifier)
|
||||
else if (state.items.isEmpty)
|
||||
const Center(
|
||||
child: Text('暂无视频', style: TextStyle(color: AppColors.textSecondary)),
|
||||
)
|
||||
else
|
||||
PageView.builder(
|
||||
controller: _pageController,
|
||||
scrollDirection: Axis.vertical,
|
||||
itemCount: state.items.length,
|
||||
onPageChanged: notifier.onPageChanged,
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.items[index];
|
||||
return FeedVideoPage(
|
||||
key: ValueKey(item.videoId),
|
||||
item: item,
|
||||
slotIndex: notifier.pool.slotOf(index),
|
||||
pool: notifier.pool,
|
||||
isCurrent: index == state.currentIndex,
|
||||
onLike: notifier.toggleLike,
|
||||
onFavorite: notifier.toggleFavorite,
|
||||
onComment: _openComments,
|
||||
onShare: _share,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 顶部:策略 Tab + 搜索(占位)
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
for (final s in FeedStrategy.values) ...[
|
||||
_StrategyTab(
|
||||
label: s.label,
|
||||
selected: s == _strategy,
|
||||
onTap: () => _switchStrategy(s),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search, color: Colors.white),
|
||||
onPressed: () => context.push('/search'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 底部:免费剩余次数提示条(≤3 时显示)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: FreeQuotaBanner(remaining: state.freeRemainingToday),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRetry(FeedNotifier notifier) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('网络异常', style: TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: notifier.refresh,
|
||||
child: const Text('点击重试', style: TextStyle(color: AppColors.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶部策略 Tab
|
||||
class _StrategyTab extends StatelessWidget {
|
||||
const _StrategyTab({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
||||
color: selected ? Colors.white : AppColors.textSecondary,
|
||||
shadows: const [Shadow(color: Colors.black54, blurRadius: 4)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||