This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
+45
View File
@@ -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
+33
View File
@@ -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'
+16
View File
@@ -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.
+28
View File
@@ -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
+14
View File
@@ -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
+44
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

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>
+24
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
+5
View File
@@ -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
+26
View File
@@ -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")
+34
View File
@@ -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
+26
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+616
View File
@@ -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>
+7
View File
@@ -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>
+13
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

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>
+26
View File
@@ -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>
+85
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+12
View File
@@ -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.
}
}
+39
View File
@@ -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);
}
+20
View File
@@ -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;
}
+186
View File
@@ -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;
}
}
+120
View File
@@ -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, '网络异常,请稍后重试');
}
}
+23
View File
@@ -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)';
}
+74
View File
@@ -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';
}
+145
View File
@@ -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();
}
}
}
+58
View File
@@ -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);
}
+44
View File
@@ -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);
}
}
}
+61
View File
@@ -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),
);
}
+18
View File
@@ -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();
}
+33
View File
@@ -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,
);
}
}
+109
View File
@@ -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 {},
),
);
}
+112
View File
@@ -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,
);
}
+42
View File
@@ -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,
);
}
+64
View File
@@ -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,
);
}
+117
View File
@@ -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?) ?? '',
);
}
+30
View File
@@ -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,
);
}
+185
View File
@@ -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(),
);
+67
View File
@@ -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();
});
+253
View File
@@ -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),
);
+290
View File
@@ -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),
);
+94
View File
@@ -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),
);
+224
View File
@@ -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),
);
+82
View File
@@ -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(),
);
+155
View File
@@ -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());
+118
View File
@@ -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),
);
+116
View File
@@ -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(),
);
+106
View File
@@ -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(),
);
+133
View File
@@ -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))),
);
}
}
+346
View File
@@ -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('点击重试')),
],
),
);
}
}
+286
View File
@@ -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),
),
],
),
);
}
}
+102
View File
@@ -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),
),
),
],
),
),
);
}
}
+195
View File
@@ -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)),
),
],
),
),
);
}
}
+227
View File
@@ -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)],
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More