chore: initialize Guangya Flutter project

This commit is contained in:
ngfchl
2026-07-18 10:23:34 +08:00
commit 6bcc3fdf18
143 changed files with 13939 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
+39
View File
@@ -0,0 +1,39 @@
# 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: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: android
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: ios
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: macos
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: windows
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
# 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'
+17
View File
@@ -0,0 +1,17 @@
# guangya_flutter
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:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
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.ptools.guangya"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.ptools.guangya"
// 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>
+69
View File
@@ -0,0 +1,69 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="光鸭云盘"
android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true"
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">
<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>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
<!-- 网络 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- 通知 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 存储读写 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- Android 10+ 媒体文件访问 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Android 11+ 管理所有文件 -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<!-- 安装应用 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- 电话状态(用于设备标识) -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
</manifest>
@@ -0,0 +1,5 @@
package com.ptools.guangya_flutter
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)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=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.14-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.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" 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
+24
View File
@@ -0,0 +1,24 @@
<?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>
</dict>
</plist>
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
View File
@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+623
View File
@@ -0,0 +1,623 @@
// !$*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 */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.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>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.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 */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.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 */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<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)";
DEVELOPMENT_TEAM = 4R6NUYZPKG;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.ptools.guangya;
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.ptools.guangya.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.ptools.guangya.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.ptools.guangya.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)";
DEVELOPMENT_TEAM = 4R6NUYZPKG;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.ptools.guangya;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 4R6NUYZPKG;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.ptools.guangya;
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>
+16
View File
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -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>
+97
View File
@@ -0,0 +1,97 @@
<?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>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>光鸭云盘</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>guangya_flutter</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>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</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>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSFileProtection</key>
<string>NSFileProtectionCompleteUntilFirstUserAuthentication</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>我们需要访问您的照片库以便上传照片。</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>用于保存截图到相册</string>
<key>NSCameraUsageDescription</key>
<string>我们需要使用相机来拍摄照片。</string>
<key>NSLocalNetworkUsageDescription</key>
<string>光鸭云盘需要访问本地网络来发现和连接设备。</string>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
</array>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+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.
}
}
+891
View File
@@ -0,0 +1,891 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import '../core/http/dio_client.dart';
import '../core/http/http.dart';
import '../core/http/http_error.dart';
import '../core/config/app_config.dart';
/// API client for Guangya Cloud Drive (光鸭云盘).
/// 基于 Dio 封装,参考 harvest_flutter 的 HTTP 架构。
class GuangyaAPI {
String accessToken;
String? refreshTokenValue;
DateTime? tokenExpiresAt;
final String deviceID;
GuangyaAPI({
this.accessToken = '',
String? refreshToken,
String? deviceID,
}) : deviceID = deviceID ?? _generateDeviceID();
static String _generateDeviceID() {
return 'flutter-${DateTime.now().millisecondsSinceEpoch}';
}
/// Update tokens from a JSON response.
AuthTokens? updateTokens(Map<String, dynamic> value) {
final access = _findStringDeep(value, ['access_token', 'accessToken']);
if (access == null) return null;
accessToken = access;
refreshTokenValue =
_findStringDeep(value, ['refresh_token', 'refreshToken']) ?? refreshTokenValue;
final expires = _findIntDeep(value, ['expires_in', 'expiresIn']);
if (expires != null) {
tokenExpiresAt = DateTime.now().add(Duration(seconds: expires));
}
return AuthTokens(
accessToken: access,
refreshToken: refreshTokenValue,
expiresIn: expires?.toDouble(),
);
}
void clearTokens() {
accessToken = '';
refreshTokenValue = null;
tokenExpiresAt = null;
}
// ── Authentication ──────────────────────────────────────────────────
Future<Map<String, dynamic>> loginSMSInit(String phoneNumber,
{String? captchaToken}) async {
final body = <String, dynamic>{
'client_id': AppConfig.clientID,
'action': 'POST:/v1/auth/verification',
'device_id': deviceID,
'meta': {'phone_number': phoneNumber},
};
if (captchaToken != null) body['captcha_token'] = captchaToken;
return Http.accountRequest('/v1/shield/captcha/init', body: body);
}
Future<Map<String, dynamic>> loginSMSSend(String phoneNumber,
{String captchaToken = '', String target = 'ANY'}) async {
return Http.accountRequest('/v1/auth/verification',
body: {
'phone_number': phoneNumber,
'target': target,
'client_id': AppConfig.clientID,
},
extraHeaders: {'x-captcha-token': captchaToken});
}
Future<Map<String, dynamic>> loginSMSVerify(
String verificationID, String verificationCode) async {
return Http.accountRequest('/v1/auth/verification/verify', body: {
'verification_id': verificationID,
'verification_code': verificationCode,
'client_id': AppConfig.clientID,
});
}
Future<Map<String, dynamic>> loginSMSSignIn({
required String code,
required String verificationToken,
required String username,
required String captchaToken,
}) async {
final result = await Http.accountRequest('/v1/auth/signin',
body: {
'verification_code': code,
'verification_token': verificationToken,
'username': username,
'client_id': AppConfig.clientID,
},
extraHeaders: {'x-captcha-token': captchaToken});
updateTokens(result);
return result;
}
Future<Map<String, dynamic>> loginQRInit() async {
return Http.accountRequest('/v1/auth/device/code', body: {
'scope': 'user',
'client_id': AppConfig.clientID,
});
}
Future<Map<String, dynamic>> loginQRPoll(String token) async {
final result = await Http.accountRequest('/v1/auth/token', body: {
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
'device_code': token,
'client_id': AppConfig.clientID,
});
updateTokens(result);
return result;
}
Future<Map<String, dynamic>> refreshAccessToken([String? token]) async {
final t = token ?? refreshTokenValue;
if (t == null) throw Exception('没有可用的刷新令牌');
final result = await Http.accountRequest('/v1/auth/token',
body: {
'client_id': AppConfig.clientID,
'grant_type': 'refresh_token',
'refresh_token': t,
},
extraHeaders: {'x-action': '401'});
updateTokens(result);
return result;
}
Future<Map<String, dynamic>> userInfo() async {
return Http.accountRequest('/v1/user/me', body: null, authenticated: true);
}
// ── Cloud downloads ────────────────────────────────────────────────
Future<Map<String, dynamic>> cloudTaskList({
int page = 0,
int pageSize = 50,
List<int> status = const [0, 1, 3, 4],
}) async {
return Http.apiRequest('/nd.bizcloudcollection.s/v1/list_task', body: {
'page': page,
'pageSize': pageSize,
'status': status,
});
}
Future<Map<String, dynamic>> cloudResolveURL(String url) async {
return Http.apiRequest('/nd.bizcloudcollection.s/v1/resolve_res',
body: {'url': url});
}
Future<Map<String, dynamic>> cloudResolveTorrent(File torrentFile) async {
final bytes = await torrentFile.readAsBytes();
final formData = FormData.fromMap({
'torrent': MultipartFile.fromBytes(bytes, filename: 'file.torrent'),
});
return Http.apiRequest('/nd.bizcloudcollection.s/v1/resolve_torrent',
body: formData);
}
Future<Map<String, dynamic>> cloudCreateTask(String url,
{String? parentID}) async {
return Http.apiRequest('/nd.bizcloudcollection.s/v1/create_task', body: {
'url': url,
'parentId': parentID ?? '',
});
}
Future<Map<String, dynamic>> taskStatus(String taskID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_task_status',
body: {'taskId': taskID});
}
// ── File system ────────────────────────────────────────────────────
Future<Map<String, dynamic>> fsFiles({
String? parentID,
int page = 0,
int pageSize = 50,
int orderBy = 0,
int sortType = 0,
List<int>? fileTypes,
int? resType,
int? dirType,
bool needPlayRecord = false,
}) async {
final body = <String, dynamic>{
'parentId': parentID ?? '',
'page': page,
'pageSize': pageSize,
'orderBy': orderBy,
'sortType': sortType,
};
if (fileTypes != null) body['fileTypes'] = fileTypes;
if (resType != null) body['resType'] = resType;
if (dirType != null) body['dirType'] = dirType;
if (needPlayRecord) body['needPlayRecord'] = true;
return Http.apiRequest('/userres/v1/file/get_file_list', body: body);
}
Future<Map<String, dynamic>> fsCreateDir(String name,
{String? parentID, bool failIfNameExist = false}) async {
final body = <String, dynamic>{
'dirName': name,
'parentId': parentID ?? '',
};
if (failIfNameExist) body['failIfNameExist'] = true;
return Http.apiRequest('/nd.bizuserres.s/v1/file/create_dir',
body: body,
allowedCodes: failIfNameExist ? [159] : []);
}
Future<Map<String, dynamic>> fsCopy(List<String> fileIDs,
{String? parentID}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/copy_file', body: {
'fileIds': fileIDs,
'parentId': parentID ?? '',
});
}
Future<Map<String, dynamic>> fsMove(List<String> fileIDs,
{String? parentID}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/move_file', body: {
'fileIds': fileIDs,
'parentId': parentID ?? '',
});
}
Future<Map<String, dynamic>> fsDelete(List<String> fileIDs) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_file',
body: {'fileIds': fileIDs});
}
Future<Map<String, dynamic>> fsRecycle(List<String> fileIDs) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/recycle_file',
body: {'fileIds': fileIDs});
}
Future<Map<String, dynamic>> fsClearRecycleBin() async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/clear_recycle_bin');
}
Future<Map<String, dynamic>> fsRename(String fileID, String newName) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/rename', body: {
'fileId': fileID,
'newName': newName,
});
}
Future<Map<String, dynamic>> fsDetail(String fileID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/get_file_detail',
body: {'fileId': fileID});
}
Future<Map<String, dynamic>> fsImageList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [1], resType: 1);
}
Future<Map<String, dynamic>> fsVideoList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [2], resType: 1);
}
Future<Map<String, dynamic>> fsAudioList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [3], resType: 1, needPlayRecord: true);
}
Future<Map<String, dynamic>> fsDocumentList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [4], resType: 1);
}
Future<Map<String, dynamic>> fsRecycleFiles() async {
return fsFiles(orderBy: 10, dirType: 4);
}
Future<Map<String, dynamic>> downloadURL(String fileID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_download_url',
body: {'fileId': fileID});
}
Future<Map<String, dynamic>> vodDownloadURL(String fileID, String gcid) async {
return Http.apiRequest('/userres/v1/file/get_vod_download_url',
body: {'fileId': fileID, 'gcid': gcid});
}
Future<Map<String, dynamic>> recentViewed({
int pageSize = 100,
String cursor = '',
}) async {
return Http.apiRequest('/userres/v1/get_user_action',
body: {'cursor': cursor, 'pageSize': pageSize});
}
Future<Map<String, dynamic>> recentRestored({int pageSize = 100}) async {
return Http.apiRequest('/userres/v1/get_restore_list', body: {
'pageSize': pageSize,
'orderBy': 2,
'sortType': 1,
});
}
// ── Shares ─────────────────────────────────────────────────────────
Future<Map<String, dynamic>> shareCreate(
List<String> fileIDs, {
required String title,
int validateDuration = 0,
int shareType = 1,
String code = '',
bool autoFillCode = true,
String trafficLimit = '0',
int maxRestoreCount = 0,
int downloadType = 1,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/share_file', body: {
'fileIds': fileIDs,
'title': title,
'validateDuration': validateDuration,
'shareType': shareType,
'code': code,
'autoFillCode': autoFillCode,
'trafficLimit': trafficLimit,
'maxRestoreCount': maxRestoreCount,
'downloadType': downloadType,
});
}
Future<Map<String, dynamic>> shareUserList({
int page = 0,
int pageSize = 50,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_list', body: {
'page': page,
'pageSize': pageSize,
'orderType': 1,
'sortType': 1,
});
}
Future<Map<String, dynamic>> shareDelete(List<String> ids) async {
return Http.apiRequest('/nd.bizuserres.s/v1/delete_share',
body: {'ids': ids});
}
Future<Map<String, dynamic>> shareUpdate(
String shareID, {
required String title,
int validateDuration = 0,
int shareType = 1,
String code = '',
bool autoFillCode = true,
String trafficLimit = '0',
int maxRestoreCount = 0,
int downloadType = 1,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/update_share', body: {
'id': shareID,
'title': title,
'validateDuration': validateDuration,
'shareType': shareType,
'code': code,
'autoFillCode': autoFillCode,
'trafficLimit': trafficLimit,
'maxRestoreCount': maxRestoreCount,
'downloadType': downloadType,
});
}
Future<Map<String, dynamic>> shareRestore(
String accessToken,
List<String> fileIDs, {
String parentID = '',
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/restore_share', body: {
'accessToken': accessToken,
'fileIds': fileIDs,
'parentId': parentID,
});
}
Future<Map<String, dynamic>> shareDownloadURL(
String fileID, String accessToken) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_download_url',
body: {'fileId': fileID, 'accessToken': accessToken});
}
Future<Map<String, dynamic>> shareFilesSize(
String accessToken,
List<String> fileIDs, {
bool download = true,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_files_size',
body: {
'accessToken': accessToken,
'fileIds': fileIDs,
'download': download,
});
}
Future<Map<String, dynamic>> shareFilesList(
String accessToken, {
String parentID = '',
int page = 1,
int pageSize = 50,
int orderBy = 0,
int sortType = 0,
}) async {
return Http.publicRequest(
'/nd.bizuserres.s/v1/get_share_page_files_list',
body: {
'accessToken': accessToken,
'parentId': parentID,
'page': page,
'pageSize': pageSize,
'orderBy': orderBy,
'sortType': sortType,
});
}
Future<Map<String, dynamic>> shareSummary(String shareID) async {
return Http.publicRequest('/nd.bizuserres.s/v1/get_share_summary',
body: {'shareId': shareID});
}
Future<Map<String, dynamic>> shareAccessToken(
String shareID, String code) async {
return Http.publicRequest('/nd.bizuserres.s/v1/get_share_access_token',
body: {'shareId': shareID, 'code': code});
}
// ── Upload ─────────────────────────────────────────────────────────
Future<Map<String, dynamic>> uploadToken({
required String name,
required int fileSize,
String? parentID,
String? md5,
}) async {
final res = <String, dynamic>{'fileSize': fileSize};
if (md5 != null) res['md5'] = _base64MD5(md5);
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
'capacity': 2,
'name': name,
'res': res,
'parentId': parentID ?? '',
}, allowedCodes: const [156]);
}
Future<Map<String, dynamic>> flashTransferToken({
required String name,
required int fileSize,
String? parentID,
required String md5,
}) async {
final normalizedMD5 = md5.trim().toLowerCase();
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
'capacity': 1,
'name': name,
'res': {'md5': normalizedMD5, 'fileSize': fileSize},
'parentId': parentID ?? '',
}, allowedCodes: const [156]);
}
Future<Map<String, dynamic>> flashTransferGCIDToken({
required String name,
required int fileSize,
String? parentID,
required String gcid,
}) async {
final normalizedGCID = gcid.trim().toUpperCase();
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
'capacity': 1,
'name': name,
'res': {'gcid': normalizedGCID, 'fileSize': fileSize},
'parentId': parentID ?? '',
}, allowedCodes: const [156]);
}
Future<Map<String, dynamic>> deleteUploadTask(List<String> taskIDs) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_upload_task',
body: {'taskIds': taskIDs});
}
Future<Map<String, dynamic>> checkCanFlashUpload(
String taskID, String gcid) async {
return Http.apiRequest('/nd.bizuserres.s/v1/check_can_flash_upload',
body: {'taskId': taskID, 'gcid': gcid});
}
Future<Map<String, dynamic>> uploadInfo(String taskID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/get_info_by_task_id',
body: {'taskId': taskID},
allowedCodes: const [145, 146, 155, 163]);
}
Future<Map<String, dynamic>> fileUpload(
File file, {
String? parentID,
String contentType = 'application/octet-stream',
int chunkSize = 5 * 1024 * 1024,
}) async {
final bytes = await file.readAsBytes();
final name = file.uri.pathSegments.isEmpty
? file.path.split(Platform.pathSeparator).last
: Uri.decodeComponent(file.uri.pathSegments.last);
final size = bytes.length;
Map<String, dynamic> token;
if (size < 1024 * 1024) {
final md5Base64 = base64Encode(md5.convert(bytes).bytes);
token = await uploadToken(
name: name,
fileSize: size,
parentID: parentID,
md5: md5Base64,
);
final taskID = _findStringDeep(token, ['taskId', 'task_id']);
if (taskID == null) return token;
return uploadInfo(taskID);
}
token = await uploadToken(name: name, fileSize: size, parentID: parentID);
final taskID = _findStringDeep(token, ['taskId', 'task_id']);
if (taskID == null) throw Exception('响应缺少字段:taskId');
final gcid = _calculateGCID(bytes);
final canFlash = await checkCanFlashUpload(taskID, gcid);
if (_findBoolDeep(canFlash, ['canFlashUpload', 'can_flash_upload']) == true) {
return uploadInfo(taskID);
}
final tokenData = _findMapDeep(token, ['data']) ?? token;
await _cdnUpload(
bytes,
tokenData: tokenData,
contentType: contentType,
chunkSize: chunkSize,
);
return uploadInfo(taskID);
}
Future<String> _cdnUpload(
List<int> bytes, {
required Map<String, dynamic> tokenData,
required String contentType,
required int chunkSize,
}) async {
final creds = _findMapDeep(tokenData, ['creds']) ?? const <String, dynamic>{};
final accessKeyID = _findStringDeep(creds, ['accessKeyID', 'accessKeyId']);
final secret = _findStringDeep(creds, ['secretAccessKey']);
final sessionToken = _findStringDeep(creds, ['sessionToken']);
final endpoint = _findStringDeep(tokenData, ['fullEndPoint', 'fullEndpoint']);
final bucket = _findStringDeep(tokenData, ['bucketName']);
final objectPath = _findStringDeep(tokenData, ['objectPath']);
if (accessKeyID == null ||
secret == null ||
sessionToken == null ||
endpoint == null ||
bucket == null ||
objectPath == null) {
throw Exception('响应缺少字段:OSS token data');
}
final objectURL = '$endpoint/$objectPath';
final init = await _ossRequest(
method: 'POST',
url: objectURL,
bucket: bucket,
objectKey: objectPath,
accessKeyID: accessKeyID,
secret: secret,
sessionToken: sessionToken,
subResources: {'uploads': ''},
);
final uploadID = _xmlValue(init.body, 'UploadId');
if (uploadID == null) throw Exception('响应缺少字段:UploadId');
final parts = <MapEntry<int, String>>[];
var offset = 0;
var number = 1;
while (offset < bytes.length) {
final end = min(offset + chunkSize, bytes.length);
final chunk = bytes.sublist(offset, end);
final contentMD5 = base64Encode(md5.convert(chunk).bytes);
final response = await _ossRequest(
method: 'PUT',
url: objectURL,
bucket: bucket,
objectKey: objectPath,
accessKeyID: accessKeyID,
secret: secret,
sessionToken: sessionToken,
content: chunk,
contentType: 'application/octet-stream',
contentMD5: contentMD5,
subResources: {'partNumber': '$number', 'uploadId': uploadID},
);
parts.add(MapEntry(number, response.headers['etag']?.replaceAll('"', '') ?? ''));
offset = end;
number += 1;
}
final xml =
'<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>${parts.map((part) => '<Part><PartNumber>${part.key}</PartNumber><ETag>"${part.value}"</ETag></Part>').join()}</CompleteMultipartUpload>';
final xmlBytes = utf8.encode(xml);
final result = await _ossRequest(
method: 'POST',
url: objectURL,
bucket: bucket,
objectKey: objectPath,
accessKeyID: accessKeyID,
secret: secret,
sessionToken: sessionToken,
content: xmlBytes,
contentType: 'application/xml',
contentMD5: base64Encode(md5.convert(xmlBytes).bytes),
subResources: {'uploadId': uploadID},
);
return _xmlValue(result.body, 'ETag') ?? '';
}
Future<({String body, Map<String, String> headers})> _ossRequest({
required String method,
required String url,
required String bucket,
required String objectKey,
required String accessKeyID,
required String secret,
required String sessionToken,
List<int> content = const [],
String contentType = '',
String contentMD5 = '',
Map<String, String> subResources = const {},
}) async {
final date = HttpDate.format(DateTime.now().toUtc());
final canonicalResource = '/$bucket/$objectKey${_subResourceQuery(subResources)}';
final canonical = [
method.toUpperCase(),
contentMD5,
contentType,
date,
'x-oss-date:$date',
'x-oss-security-token:${sessionToken.trim()}',
canonicalResource,
].join('\n');
final signature = base64Encode(
Hmac(sha1, utf8.encode(secret)).convert(utf8.encode(canonical)).bytes,
);
final uri = Uri.parse('$url${_subResourceQuery(subResources)}');
final response = await Dio().requestUri<List<int>>(
uri,
data: content.isEmpty ? null : Stream.fromIterable([content]),
options: Options(
method: method,
responseType: ResponseType.bytes,
headers: {
'Authorization': 'OSS $accessKeyID:$signature',
'x-oss-date': date,
'x-oss-security-token': sessionToken,
if (contentType.isNotEmpty) 'Content-Type': contentType,
if (contentMD5.isNotEmpty) 'Content-MD5': contentMD5,
},
),
);
final status = response.statusCode ?? 0;
if (status < 200 || status >= 300) {
throw Exception('OSS 请求失败 ($status)');
}
return (
body: utf8.decode(response.data ?? const [], allowMalformed: true),
headers: response.headers.map.map(
(key, value) => MapEntry(key.toLowerCase(), value.join(',')),
),
);
}
// ── TMDB ───────────────────────────────────────────────────────────
Future<Map<String, dynamic>> tmdbSearch(
String query, {
required String apiKey,
String mediaKind = 'auto',
String proxyHost = '',
String proxyPort = '',
int? year,
}) async {
final endpoint = mediaKind == 'movie'
? 'movie'
: (mediaKind == 'tv' ? 'tv' : 'multi');
final params = {
'api_key': apiKey,
'query': query,
'language': 'zh-CN',
'region': 'CN',
'include_adult': 'false',
};
if (year != null) {
if (mediaKind == 'movie') {
params['primary_release_year'] = year.toString();
} else if (mediaKind == 'tv') {
params['first_air_date_year'] = year.toString();
}
}
final uri = Uri.https('api.themoviedb.org', '/3/search/$endpoint', params);
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
}
Future<Map<String, dynamic>> tmdbDetails(
int id, {
required String mediaKind,
required String apiKey,
String proxyHost = '',
String proxyPort = '',
}) async {
final endpoint = mediaKind == 'tv' ? 'tv' : 'movie';
final params = {
'api_key': apiKey,
'language': 'zh-CN',
'append_to_response': 'credits,images,external_ids,translations',
'include_image_language': 'zh-CN,zh,null,en',
};
final uri = Uri.https('api.themoviedb.org', '/3/$endpoint/$id', params);
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
}
Future<Map<String, dynamic>> tmdbEpisodeDetails(
int seriesID, {
required int season,
required int episode,
required String apiKey,
String proxyHost = '',
String proxyPort = '',
}) async {
final params = {
'api_key': apiKey,
'language': 'zh-CN',
'append_to_response': 'credits,images,translations',
'include_image_language': 'zh-CN,zh,null,en',
};
final uri = Uri.https('api.themoviedb.org',
'/3/tv/$seriesID/season/$season/episode/$episode', params);
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
}
// ── HTTP helpers ───────────────────────────────────────────────────
Future<Map<String, dynamic>> _tmdbRequest(
Uri url, {
String proxyHost = '',
String proxyPort = '',
}) async {
final dio = DioClient.dio;
final response = await dio.getUri(url, options: Options(
headers: {'Accept': 'application/json'},
));
if (response.statusCode! < 200 || response.statusCode! >= 300) {
throw Exception('TMDB 请求失败');
}
return response.data as Map<String, dynamic>;
}
// ── Helpers ───────────────────────────────────────────────────────
static String? _findStringDeep(Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final v = json[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
for (final entry in json.entries) {
if (entry.value is Map<String, dynamic>) {
final found =
_findStringDeep(entry.value as Map<String, dynamic>, keys);
if (found != null) return found;
}
}
return null;
}
static int? _findIntDeep(Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final v = json[key];
if (v != null) return v is int ? v : int.tryParse(v.toString());
}
return null;
}
static bool? _findBoolDeep(Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final value = json[key];
if (value is bool) return value;
if (value != null) {
final text = value.toString().toLowerCase();
if (text == 'true' || text == '1') return true;
if (text == 'false' || text == '0') return false;
}
}
for (final entry in json.entries) {
if (entry.value is Map) {
final found = _findBoolDeep(
Map<String, dynamic>.from(entry.value as Map),
keys,
);
if (found != null) return found;
}
}
return null;
}
static Map<String, dynamic>? _findMapDeep(
Map<String, dynamic>? json,
List<String> keys,
) {
if (json == null) return null;
for (final key in keys) {
final value = json[key];
if (value is Map) return Map<String, dynamic>.from(value);
}
for (final entry in json.entries) {
if (entry.value is Map) {
final found = _findMapDeep(
Map<String, dynamic>.from(entry.value as Map),
keys,
);
if (found != null) return found;
}
}
return null;
}
static String _subResourceQuery(Map<String, String> values) {
if (values.isEmpty) return '';
final keys = values.keys.toList()..sort();
return '?${keys.map((key) {
final value = values[key] ?? '';
return value.isEmpty ? key : '$key=$value';
}).join('&')}';
}
static String? _xmlValue(String xml, String tag) {
final match = RegExp('<$tag>(.*?)</$tag>').firstMatch(xml);
return match?.group(1);
}
static String _base64MD5(String value) {
final normalized = value.trim();
if (!RegExp(r'^[A-Fa-f0-9]{32}$').hasMatch(normalized)) {
return normalized;
}
final bytes = <int>[];
for (var i = 0; i < normalized.length; i += 2) {
bytes.add(int.parse(normalized.substring(i, i + 2), radix: 16));
}
return base64Encode(bytes);
}
static String _calculateGCID(List<int> bytes) {
final length = bytes.length;
final chunkSize = length <= 0x8000000
? 262144
: length <= 0x10000000
? 524288
: length <= 0x20000000
? 1048576
: 2097152;
final hashes = <int>[];
for (var offset = 0; offset < bytes.length; offset += chunkSize) {
final end = min(offset + chunkSize, bytes.length);
hashes.addAll(sha1.convert(bytes.sublist(offset, end)).bytes);
}
return sha1
.convert(hashes)
.bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.join();
}
void dispose() {
// DioClient 的 Dio 实例是静态的,不需要手动关闭
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../providers/theme_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/file_provider.dart';
import '../providers/media_library_provider.dart';
import '../pages/login_page.dart';
import '../pages/workspace_page.dart';
import 'app_theme.dart';
class GuangyaApp extends ConsumerWidget {
const GuangyaApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeState = ref.watch(themeProvider);
final auth = ref.watch(authProvider);
// Auto-load files when signed in
if (auth.isSignedIn) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final fp = ref.read(fileProvider.notifier);
fp.api = ref.read(authProvider.notifier).api;
ref.read(mediaLibraryProvider.notifier).api = ref
.read(authProvider.notifier)
.api;
final fileState = ref.read(fileProvider);
if (fileState.files.isEmpty && !fileState.isLoading) {
fp.loadFiles();
}
});
}
return ShadApp(
title: '光鸭云盘',
debugShowCheckedModeBanner: false,
theme: lightTheme,
darkTheme: darkTheme,
themeMode: themeState.themeMode,
home: auth.isLoading
? const Scaffold(body: Center(child: CircularProgressIndicator()))
: auth.isSignedIn
? const WorkspacePage()
: const LoginPage(),
);
}
}
+152
View File
@@ -0,0 +1,152 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
// Light theme
final lightTheme = ShadThemeData(
colorScheme: const ShadOrangeColorScheme.light(
background: Color(0xFFFFFFFF),
foreground: Color(0xFF0F172A),
card: Color(0xFFFFFFFF),
cardForeground: Color(0xFF0F172A),
popover: Color(0xFFFFFFFF),
popoverForeground: Color(0xFF0F172A),
primary: Color(0xFFF97316),
primaryForeground: Color(0xFFFFFFFF),
secondary: Color(0xFFF1F5F9),
secondaryForeground: Color(0xFF0F172A),
muted: Color(0xFFF1F5F9),
mutedForeground: Color(0xFF64748B),
accent: Color(0xFFF1F5F9),
accentForeground: Color(0xFF0F172A),
destructive: Color(0xFFEF4444),
destructiveForeground: Color(0xFFFFFFFF),
border: Color(0xFFE2E8F0),
input: Color(0xFFE2E8F0),
ring: Color(0xFFF97316),
selection: Color(0xFFFB923C),
),
);
// Dark theme
final darkTheme = ShadThemeData(
colorScheme: const ShadOrangeColorScheme.dark(
background: Color(0xFF0F172A),
foreground: Color(0xFFF8FAFC),
card: Color(0xFF1E293B),
cardForeground: Color(0xFFF8FAFC),
popover: Color(0xFF1E293B),
popoverForeground: Color(0xFFF8FAFC),
primary: Color(0xFFF97316),
primaryForeground: Color(0xFFFFFFFF),
secondary: Color(0xFF1E293B),
secondaryForeground: Color(0xFFF8FAFC),
muted: Color(0xFF1E293B),
mutedForeground: Color(0xFF94A3B8),
accent: Color(0xFF1E293B),
accentForeground: Color(0xFFF8FAFC),
destructive: Color(0xFFEF4444),
destructiveForeground: Color(0xFFFFFFFF),
border: Color(0xFF334155),
input: Color(0xFF334155),
ring: Color(0xFFF97316),
selection: Color(0xFFFB923C),
),
);
class GlassCard extends StatelessWidget {
final Widget child;
final double borderRadius;
final EdgeInsets? padding;
const GlassCard({
super.key,
required this.child,
this.borderRadius = 24,
this.padding,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Container(
padding: padding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: child,
),
),
);
}
}
class OS26Surface extends StatelessWidget {
final Widget child;
const OS26Surface({super.key, required this.child});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFE9F6F5), Color(0xFFF6FAF6), Color(0xFFF9E6D4)],
),
),
child: child,
);
}
}
class OS26Glass extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final double radius;
final double opacity;
final Border? border;
const OS26Glass({
super.key,
required this.child,
this.padding,
this.radius = 18,
this.opacity = 0.48,
this.border,
});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: opacity),
borderRadius: BorderRadius.circular(radius),
border:
border ??
Border.all(
color: Colors.white.withValues(alpha: 0.58),
width: 1,
),
),
child: child,
),
),
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import '../storage/storage_manager.dart';
class AppConfig {
/// 光鸭 API 基础地址
static const String _defaultApiBase = 'https://api.guangyapan.com';
/// 光鸭账号基础地址
static const String _defaultAccountBase = 'https://account.guangyapan.com';
/// 客户端 ID
static const String clientID = 'aMe-8VSlkrbQXpUR';
/// 获取 API baseUrl(动态)
static String get apiBase {
return StorageManager.get<String>(StorageKeys.apiBase) ?? _defaultApiBase;
}
/// 设置 API baseUrl
static Future<void> setApiBase(String url) async {
await StorageManager.set(StorageKeys.apiBase, normalizeUrl(url));
}
/// 获取账号 baseUrl
static String get accountBase {
return StorageManager.get<String>(StorageKeys.accountBase) ?? _defaultAccountBase;
}
/// 设置账号 baseUrl
static Future<void> setAccountBase(String url) async {
await StorageManager.set(StorageKeys.accountBase, normalizeUrl(url));
}
static String normalizeUrl(String url) {
var value = url.trim();
while (value.endsWith('/')) {
value = value.substring(0, value.length - 1);
}
return value;
}
/// 清除所有配置
static Future<void> clear() async {
await StorageManager.delete(StorageKeys.apiBase);
await StorageManager.delete(StorageKeys.accountBase);
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import '../config/app_config.dart';
import 'interceptors/auth_interceptor.dart';
import 'interceptors/response_interceptor.dart';
class DioClient {
static late Dio dio;
static late Dio accountDio;
static AuthInterceptor? _authInterceptor;
/// 初始化 DIO 客户端
static void init({OnLogout? onLogout}) {
_authInterceptor = AuthInterceptor(onLogout: onLogout);
dio = _createDio(
baseUrl: AppConfig.apiBase,
interceptors: [
_authInterceptor!,
ResponseInterceptor(),
],
);
accountDio = _createDio(
baseUrl: AppConfig.accountBase,
interceptors: [
ResponseInterceptor(),
],
);
}
/// 更新 API baseUrl 并同步到 DioClient
static void updateBaseUrl(String apiBase, {String? accountBase}) {
dio.options.baseUrl = apiBase;
if (accountBase != null) {
accountDio.options.baseUrl = accountBase;
}
}
/// 重新设置 onLogout 回调(例如切换 provider 时)
static void setOnLogout(OnLogout? onLogout) {
if (_authInterceptor != null) {
_authInterceptor = AuthInterceptor(onLogout: onLogout);
dio.interceptors.removeWhere((i) => i is AuthInterceptor);
dio.interceptors.insert(0, _authInterceptor!);
}
}
static Dio _createDio({
required String baseUrl,
required List<Interceptor> interceptors,
}) {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'Origin': 'https://www.guangyapan.com',
'Referer': 'https://www.guangyapan.com/',
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
),
);
// Windows 下优化连接池
if (Platform.isWindows) {
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.maxConnectionsPerHost = 10;
client.idleTimeout = const Duration(seconds: 60);
return client;
};
}
dio.interceptors.addAll(interceptors);
return dio;
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import '../storage/storage_manager.dart';
import 'dio_client.dart';
import 'http_error.dart';
import 'interceptors/response_interceptor.dart';
/// 统一 HTTP 封装 — 光鸭云盘
class Http {
/// 通用底层请求方法
static Future<T> request<T>(
String path, {
String method = 'POST',
Map<String, dynamic>? queryParameters,
dynamic data,
Map<String, dynamic>? headers,
CancelToken? cancelToken,
Options? options,
bool useAccountDio = false,
bool allowAnyCode = false,
}) async {
final client = useAccountDio ? DioClient.accountDio : DioClient.dio;
final mergedOptions = (options ?? Options()).copyWith(
method: method,
headers: headers,
);
final stopwatch = Stopwatch()..start();
final endpoint = useAccountDio ? 'account' : 'api';
log('[HTTP:$endpoint] -> $method $path');
try {
final res = await client.request(
path,
data: data,
queryParameters: queryParameters,
cancelToken: cancelToken,
options: mergedOptions,
);
stopwatch.stop();
log(
'[HTTP:$endpoint] <- $method $path status=${res.statusCode} '
'elapsed=${stopwatch.elapsedMilliseconds}ms',
);
return res.data as T;
} on DioException catch (e) {
stopwatch.stop();
final status = e.response?.statusCode;
log(
'[HTTP:$endpoint] !! $method $path status=${status ?? '-'} '
'type=${e.type.name} elapsed=${stopwatch.elapsedMilliseconds}ms',
);
// 将业务错误信息包装为 ApiException
final errorMsg = e.error is String ? (e.error as String) : '';
if (errorMsg.isNotEmpty) {
throw ApiException(status: status, message: errorMsg);
}
rethrow;
} catch (e) {
stopwatch.stop();
log(
'[HTTP:$endpoint] !! $method $path elapsed=${stopwatch.elapsedMilliseconds}ms',
);
rethrow;
}
}
/// 光鸭 API 请求(带 tokencode==200 为成功)
static Future<Map<String, dynamic>> apiRequest(
String path, {
String method = 'POST',
dynamic body,
Map<String, dynamic>? headers,
CancelToken? cancelToken,
List<int> allowedCodes = const [],
}) async {
// 过期自动刷新
_checkTokenExpiry();
final mergedHeaders = <String, dynamic>{
'traceparent': _generateTraceparent(),
...?headers,
};
final data = await request<Map<String, dynamic>>(
path,
method: method,
data: body,
headers: mergedHeaders,
cancelToken: cancelToken,
options: Options(extra: {ResponseInterceptor.skipCodeCheckKey: true}),
);
final code = _businessCode(data['code']);
if (code == null ||
code == 0 ||
code == 200 ||
allowedCodes.contains(code)) {
return data;
}
throw ApiException(
status: code,
message: extractHttpMessage(data) ?? '请求失败 ($code)',
);
}
/// 光鸭账号请求(account API 响应格式无 code 字段,跳过业务 code 检查)
static Future<Map<String, dynamic>> accountRequest(
String path, {
String method = 'POST',
Map<String, dynamic>? body,
Map<String, dynamic>? extraHeaders,
bool authenticated = false,
}) async {
final headers = <String, dynamic>{
'did': _getDeviceID(),
'dt': '4',
...?extraHeaders,
};
if (authenticated) {
final token = StorageManager.get<String>(StorageKeys.accessToken);
if (token != null && token.isNotEmpty) {
headers['authorization'] = 'Bearer $token';
}
}
final data = await request<Map<String, dynamic>>(
path,
method: method,
data: body,
headers: headers,
useAccountDio: true,
options: Options(extra: {ResponseInterceptor.skipCodeCheckKey: true}),
);
return data;
}
/// 公开请求(无 token,用于分享预览等)
static Future<Map<String, dynamic>> publicRequest(
String path, {
Map<String, dynamic>? body,
}) async {
return request<Map<String, dynamic>>(
path,
method: 'POST',
data: body,
headers: {'traceparent': _generateTraceparent()},
);
}
// ── Token 过期检测 ─────────────────────────────────────────────
static void _checkTokenExpiry() {
final expiresAt = StorageManager.get<int>(StorageKeys.tokenExpiresAt);
if (expiresAt == null) return;
if (DateTime.now().millisecondsSinceEpoch > expiresAt) {
log('[HTTP] token expired, will refresh on next 401');
}
}
static String _getDeviceID() {
return StorageManager.get<String>(StorageKeys.deviceID) ??
'flutter-${DateTime.now().millisecondsSinceEpoch}';
}
// ── Helpers ─────────────────────────────────────────────────────
static String _generateTraceparent() {
final now = DateTime.now().microsecondsSinceEpoch;
return '00-${now.toRadixString(16).padLeft(32, '0')}-${now.toRadixString(16).padLeft(16, '0')}-01';
}
static int? _businessCode(dynamic value) {
if (value == null) return null;
if (value is int) return value;
return int.tryParse(value.toString());
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:dio/dio.dart';
const String noToastHeader = 'NoToast';
const String suppressErrorToastExtra = 'suppressErrorToast';
bool suppressErrorToast(RequestOptions options) {
return options.extra[suppressErrorToastExtra] == true;
}
void applyNoToastHeader(RequestOptions options) {
String? matchedKey;
Object? matchedValue;
for (final entry in options.headers.entries) {
if (entry.key.toLowerCase() == noToastHeader.toLowerCase()) {
matchedKey = entry.key;
matchedValue = entry.value;
break;
}
}
if (matchedKey == null) return;
options.headers.remove(matchedKey);
if (_isNoToastValue(matchedValue)) {
options.extra[suppressErrorToastExtra] = true;
}
}
bool _isNoToastValue(Object? value) {
if (value == null) return true;
final text = value.toString().trim().toLowerCase();
return text.isEmpty || text == '1' || text == 'true' || text == 'yes';
}
/// Extracts a human-readable message from various response shapes
String? extractHttpMessage(dynamic value) {
if (value == null) return null;
if (value is String) return value.trim().isEmpty ? null : value.trim();
if (value is Map) {
for (final key in const ['message', 'msg', 'info', 'detail', 'error']) {
final message = extractHttpMessage(value[key]);
if (message != null) return message;
}
return extractHttpMessage(value['data']);
}
if (value is Iterable) {
final messages = value
.map(extractHttpMessage)
.whereType<String>()
.where((message) => message.trim().isNotEmpty)
.toList();
return messages.isEmpty ? null : messages.join('\n');
}
return null;
}
/// Build a user-friendly toast message from request context + detail
String requestToastMessage(RequestOptions options, String detail) {
final endpoint = _describePath(options.path);
final trimmed = detail.trim();
if (trimmed.isEmpty) return endpoint;
return '$endpoint$trimmed';
}
String _describePath(String path) {
if (path.contains('/auth')) return '认证接口';
if (path.contains('/token')) return '令牌接口';
if (path.contains('/file')) return '文件接口';
if (path.contains('/share')) return '分享接口';
if (path.contains('/res')) return '资源接口';
if (path.contains('/task') || path.contains('/cloud')) return '云下载接口';
return '接口请求';
}
/// Custom exception for API errors
class ApiException implements Exception {
final int? status;
final String message;
ApiException({this.status, required this.message});
@override
String toString() => message;
}
class AuthTokens {
final String accessToken;
final String? refreshToken;
final double? expiresIn;
AuthTokens({
required this.accessToken,
this.refreshToken,
this.expiresIn,
});
}
@@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:developer';
import 'package:dio/dio.dart';
import '../../config/app_config.dart';
import '../../storage/storage_manager.dart';
import '../http_error.dart';
import '../dio_client.dart';
/// 光鸭 OAuth token 刷新回调
typedef OnLogout = Future<void> Function();
class AuthInterceptor extends Interceptor {
final OnLogout? onLogout;
bool _isRefreshing = false;
bool _logoutScheduled = false;
final List<Completer<void>> _waitQueue = [];
AuthInterceptor({this.onLogout});
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
applyNoToastHeader(options);
// 免认证路径:登录、刷新等
if (_isAuthExemptPath(options.path)) {
return handler.next(options);
}
final token = StorageManager.get<String>(StorageKeys.accessToken);
if (token != null && token.isNotEmpty) {
_logoutScheduled = false;
options.headers['authorization'] = 'Bearer $token';
return handler.next(options);
}
// 无 token → 静默取消
return handler.reject(_silentCancel(options));
}
bool _isAuthExemptPath(String path) {
return path.contains('/auth/signin') ||
path.contains('/auth/verification') ||
path.contains('/auth/device/code') ||
path.contains('/auth/token');
}
bool _isSilentCancel(DioException err) {
return err.type == DioExceptionType.cancel &&
err.error?.toString() == 'silent_auth_cancel';
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (_isSilentCancel(err)) {
return handler.next(err);
}
final status = err.response?.statusCode;
final alreadyLoggedOut = status == 401 && _isAlreadyLoggedOut;
if (!alreadyLoggedOut) {
log('[Auth] error path=${err.requestOptions.path} status=$status');
}
// 免认证路径的错误直接放行
if (_isAuthExemptPath(err.requestOptions.path)) {
return handler.next(err);
}
// 网络错误
if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.receiveTimeout) {
return handler.next(err);
}
// 非 401 → 直接传递
if (status != 401) {
return handler.next(err);
}
// 以下全部是 401 处理
if (alreadyLoggedOut) {
return handler.reject(_silentCancel(err.requestOptions));
}
// 刷新接口本身 401 → refresh token 也失效了
if (err.requestOptions.path.contains('/auth/token') &&
err.requestOptions.method == 'POST') {
await _logout();
return handler.reject(_silentCancel(err.requestOptions));
}
// 获取 refreshToken
final refreshToken = StorageManager.get<String>(StorageKeys.refreshToken);
// 没有 refreshToken → 静默登出
if (refreshToken == null || refreshToken.isEmpty) {
await _logout();
return handler.reject(_silentCancel(err.requestOptions));
}
// 已经在刷新中 → 排队等待
if (_isRefreshing) {
final completer = Completer<void>();
_waitQueue.add(completer);
await completer.future;
final newToken = StorageManager.get<String>(StorageKeys.accessToken);
if (newToken == null || newToken.isEmpty) {
return handler.reject(_silentCancel(err.requestOptions));
}
final request = err.requestOptions;
request.headers['authorization'] = 'Bearer $newToken';
try {
final response = await DioClient.dio.fetch(request);
return handler.resolve(response);
} catch (e) {
return handler.reject(_silentCancel(err.requestOptions));
}
}
// 开始刷新
_isRefreshing = true;
log('[Auth] refreshing access token');
try {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.accountBase,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
),
);
final res = await dio.post(
'/v1/auth/token',
data: {
'client_id': AppConfig.clientID,
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
},
options: Options(
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'Origin': 'https://www.guangyapan.com',
'Referer': 'https://www.guangyapan.com/',
'did': StorageManager.get<String>(StorageKeys.deviceID) ?? '',
'dt': '4',
'x-action': '401',
},
),
);
log('[Auth] access token refreshed');
final data = res.data;
final newAccess = _findStringDeep(data, ['access_token', 'accessToken']);
final newRefresh = _findStringDeep(data, [
'refresh_token',
'refreshToken',
]);
if (newAccess == null) {
throw Exception('刷新令牌返回数据无效');
}
// 保存新 token
await StorageManager.set(StorageKeys.accessToken, newAccess);
if (newRefresh != null) {
await StorageManager.set(StorageKeys.refreshToken, newRefresh);
}
// 保存过期时间
final expiresIn = data is Map ? data['expires_in'] : null;
if (expiresIn != null) {
final expiresAt =
DateTime.now().millisecondsSinceEpoch +
(expiresIn is int
? expiresIn
: int.tryParse(expiresIn.toString()) ?? 3600) *
1000;
await StorageManager.set(StorageKeys.tokenExpiresAt, expiresAt);
}
// 唤醒排队的请求
for (var c in _waitQueue) {
c.complete();
}
_waitQueue.clear();
// 用新 token 重试当前请求
final request = err.requestOptions;
request.headers['authorization'] = 'Bearer $newAccess';
final response = await DioClient.dio.fetch(request);
return handler.resolve(response);
} catch (e) {
log('[Auth] token refresh failed: $e');
for (var c in _waitQueue) {
c.complete();
}
_waitQueue.clear();
await _logout();
return handler.reject(_silentCancel(err.requestOptions));
} finally {
_isRefreshing = false;
}
}
DioException _silentCancel(RequestOptions options) {
return DioException(
requestOptions: options,
type: DioExceptionType.cancel,
error: 'silent_auth_cancel',
);
}
Future<void> _logout() async {
if (_logoutScheduled) return;
_logoutScheduled = true;
log('[Auth] logout scheduled');
await StorageManager.delete(StorageKeys.accessToken);
await StorageManager.delete(StorageKeys.refreshToken);
await StorageManager.delete(StorageKeys.tokenExpiresAt);
final logout = onLogout;
if (logout != null) {
await logout();
}
_logoutScheduled = false;
}
bool get _isAlreadyLoggedOut {
return StorageManager.get<String>(StorageKeys.accessToken) == null &&
StorageManager.get<String>(StorageKeys.refreshToken) == null;
}
static String? _findStringDeep(dynamic value, List<String> keys) {
if (value is Map) {
for (final key in keys) {
final v = value[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
for (final entry in value.entries) {
if (entry.value is Map) {
final found = _findStringDeep(entry.value, keys);
if (found != null) return found;
}
}
}
return null;
}
}
@@ -0,0 +1,113 @@
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import '../http_error.dart';
class ResponseInterceptor extends Interceptor {
/// Extra key: true = account API (skip business code check)
static const String skipCodeCheckKey = 'skipBusinessCodeCheck';
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final data = response.data;
// 流式响应直接放行
if (data is ResponseBody) {
return handler.next(response);
}
// 尝试解析 JSON
if (data is String) {
try {
response.data = jsonDecode(data);
} catch (_) {
// non-JSON response, pass through
}
}
final body = response.data;
if (body is! Map) {
return handler.next(response);
}
// Account API 跳过业务 code 检查(响应格式是 {data: {...}},没有 code 字段)
final skipCodeCheck =
response.requestOptions.extra[skipCodeCheckKey] == true ||
response.requestOptions.headers['x-skip-code-check'] == true;
if (skipCodeCheck) {
return handler.next(response);
}
// 业务 API:检查 code 字段
final code = _businessCode(body['code']);
final message = extractHttpMessage(body);
// code == 0/200 视为成功。不同网关返回的业务成功码不完全一致。
if (code == null || code == 0 || code == 200) {
if (message != null && message.isNotEmpty) {
response.extra['success_message'] = message;
}
return handler.next(response);
}
// 其他 code 视为业务错误
final errorMsg = message ?? _defaultErrorMessage(code);
return handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: errorMsg,
),
);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.type == DioExceptionType.cancel) {
return handler.next(err);
}
// 网络错误
if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.receiveTimeout) {
if (!suppressErrorToast(err.requestOptions)) {
log(
'[HTTP] ${requestToastMessage(err.requestOptions, '请求超时或连接失败,请检查网络')}',
);
}
return handler.next(err);
}
// 其他错误
if (!suppressErrorToast(err.requestOptions)) {
final msg = err.response?.data is Map
? extractHttpMessage(err.response?.data) ?? '请求失败'
: '请求失败';
log('[HTTP] ${requestToastMessage(err.requestOptions, msg)}');
}
return handler.next(err);
}
static String? _defaultErrorMessage(dynamic code) {
if (code == 400) return '请求参数错误';
if (code == 401) return '未授权,请重新登录';
if (code == 403) return '没有权限执行此操作';
if (code == 404) return '接口不存在';
if (code == 500) return '服务器内部错误';
return '请求失败 ($code)';
}
static int? _businessCode(dynamic value) {
if (value == null) return null;
if (value is int) return value;
return int.tryParse(value.toString());
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:hive_flutter/hive_flutter.dart';
class StorageKeys {
static const String apiBase = 'guangya.apiBase';
static const String accountBase = 'guangya.accountBase';
static const String accessToken = 'guangya.accessToken';
static const String refreshToken = 'guangya.refreshToken';
static const String deviceID = 'guangya.deviceID';
static const String tokenExpiresAt = 'guangya.tokenExpiresAt';
static const String baseUrl = 'guangya.baseUrl';
static const String themeMode = 'guangya.themeMode';
static const String tmdbApiKey = 'guangya.tmdbApiKey';
static const String tmdbProxyHost = 'guangya.tmdbProxyHost';
static const String tmdbProxyPort = 'guangya.tmdbProxyPort';
static const String mediaLibraries = 'guangya.mediaLibraries';
static const String mediaLibraryItems = 'guangya.mediaLibraryItems';
}
class StorageManager {
static const String _boxName = 'guangya';
static Box? _box;
static Future<void> init() async {
await Hive.initFlutter();
_box = await Hive.openBox(_boxName);
}
static Box get _instance {
if (_box == null) throw StateError('StorageManager not initialized');
return _box!;
}
static T? get<T>(String key) => _box?.get(key) as T?;
static Future<void> set(String key, dynamic value) async {
if (value == null) {
await _instance.delete(key);
} else {
await _instance.put(key, value);
}
}
static Future<void> delete(String key) async {
await _instance.delete(key);
}
static Future<void> clear() async {
await _instance.clear();
}
}
+53
View File
@@ -0,0 +1,53 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart';
import 'core/storage/storage_manager.dart';
import 'core/http/dio_client.dart';
import 'app/app.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Init Hive for persistent storage
await StorageManager.init();
// Init Dio HTTP client
DioClient.init();
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
await windowManager.ensureInitialized();
const options = WindowOptions(
size: Size(1280, 820),
minimumSize: Size(980, 640),
center: true,
backgroundColor: Colors.transparent,
titleBarStyle: TitleBarStyle.hidden,
);
windowManager.waitUntilReadyToShow(options, () async {
await windowManager.show();
await windowManager.focus();
});
}
// Trigger network permission
_triggerNetworkPermission();
runApp(const ProviderScope(child: GuangyaApp()));
}
/// Startup network permission trigger
void _triggerNetworkPermission() {
Future.delayed(const Duration(seconds: 2), () async {
try {
final url = Uri.parse('https://www.baidu.com');
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
final request = await client.getUrl(url);
await request.close();
client.close();
} catch (_) {
// Silently ignore - permission dialog may have been shown
}
});
}
+260
View File
@@ -0,0 +1,260 @@
import 'package:intl/intl.dart';
/// Cloud file model representing a file or folder in the cloud drive.
class CloudFile {
static const supportedVideoExtensions = {
'mp4',
'm4v',
'mkv',
'mov',
'avi',
'ts',
'm2ts',
'mts',
'webm',
'flv',
'f4v',
'wmv',
'asf',
'mpg',
'mpeg',
'vob',
'iso',
'rm',
'rmvb',
'3gp',
'ogv',
};
final String id;
final String name;
final bool isDirectory;
final int? size;
final String? gcid;
final int? subDirectoryCount;
final int? subFileCount;
final String modifiedAt;
final String cloudPath;
final int fileType;
const CloudFile({
required this.id,
required this.name,
required this.isDirectory,
this.size,
this.gcid,
this.subDirectoryCount,
this.subFileCount,
this.modifiedAt = '',
this.cloudPath = '',
this.fileType = 0,
});
bool get isVideo {
if (isDirectory) return false;
if (fileType == 2) return true;
final ext = name.split('.').last.toLowerCase();
return supportedVideoExtensions.contains(ext);
}
bool get isImage => fileType == 1;
bool get isAudio => fileType == 3;
bool get isDocument => fileType == 4;
String get icon {
if (isDirectory) return 'folder';
if (isVideo) return 'movie';
switch (fileType) {
case 1:
return 'image';
case 2:
return 'movie';
case 3:
return 'music_note';
case 4:
return 'description';
case 5:
case 9:
return 'archive';
default:
return 'insert_drive_file';
}
}
String get typeName {
if (isDirectory) return '文件夹';
if (isVideo) return '视频';
const names = {1: '图片', 2: '视频', 3: '音频', 4: '文档', 5: '压缩包', 9: 'BT种子'};
return names[fileType] ?? '文件';
}
String get formattedSize {
if (size == null) return '--';
return _formatBytes(size!);
}
static String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
factory CloudFile.fromJson(Map<String, dynamic> json) {
final name =
json['name'] ??
json['fileName'] ??
json['file_name'] ??
json['resName'] ??
json['res_name'] ??
json['dirName'] ??
json['dir_name'] ??
'';
final id = _extractId(json);
if (id.isEmpty) throw FormatException('Missing file ID');
final resourceType = _extractInt(json, ['resType']);
final type = _extractInt(json, ['fileType', 'type']) ?? 0;
bool isDir;
final explicitDir = json['isDir'] ?? json['dir'] ?? json['directoryType'];
if (explicitDir != null) {
isDir = _truthyDirectoryFlag(explicitDir);
} else if (resourceType != null) {
isDir = resourceType == 2;
} else {
isDir =
type == 0 && (json['dirName'] != null || json['children'] != null);
}
int? fileSize = _extractIntDeep(json, [
'size',
'fileSize',
'resSize',
'totalSize',
'dirSize',
'folderSize',
]);
if (isDir && fileSize == null) fileSize = 0;
final epoch = _extractIntDeep(json, ['utime', 'ctime']);
return CloudFile(
id: id,
name: name.toString(),
isDirectory: isDir,
size: fileSize,
gcid: _extractStringDeep(json, ['gcid', 'gcId', 'gcidValue', 'hash']),
subDirectoryCount: _extractIntDeep(json, ['subDirCount']),
subFileCount: _extractIntDeep(json, ['subFileCount']),
modifiedAt:
json['updateTime']?.toString() ??
json['updatedAt']?.toString() ??
json['modifyTime']?.toString() ??
json['createTime']?.toString() ??
(epoch == null ? null : _formatEpoch(epoch)) ??
'',
cloudPath:
_extractString(json, ['location', 'path', 'fullPath']) ??
name.toString(),
fileType: type,
);
}
static String _extractId(Map<String, dynamic> json) {
for (final key in ['fileId', 'file_id', 'resId', 'res_id', 'fid', 'id']) {
final v = json[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
return '';
}
static String? _extractString(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final v = json[key];
if (v != null) return v.toString();
}
return null;
}
static int? _extractInt(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final v = json[key];
final value = _toInt(v);
if (value != null) return value;
}
return null;
}
static String? _extractStringDeep(
Map<String, dynamic> json,
List<String> keys,
) {
final direct = _extractString(json, keys);
if (direct != null && direct.isNotEmpty) return direct;
for (final entry in json.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
final found = _extractStringDeep(value, keys);
if (found != null && found.isNotEmpty) return found;
} else if (value is List) {
for (final item in value) {
if (item is Map<String, dynamic>) {
final found = _extractStringDeep(item, keys);
if (found != null && found.isNotEmpty) return found;
}
}
}
}
return null;
}
static int? _extractIntDeep(Map<String, dynamic> json, List<String> keys) {
final direct = _extractInt(json, keys);
if (direct != null) return direct;
for (final entry in json.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
final found = _extractIntDeep(value, keys);
if (found != null) return found;
} else if (value is List) {
for (final item in value) {
if (item is Map<String, dynamic>) {
final found = _extractIntDeep(item, keys);
if (found != null) return found;
}
}
}
}
return null;
}
static int? _toInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is double) return value.toInt();
return int.tryParse(value.toString());
}
static bool _truthyDirectoryFlag(dynamic value) {
if (value is bool) return value;
if (value is num) return value == 1;
final text = value.toString().toLowerCase();
return text == '1' || text == 'true' || text == 'folder' || text == 'dir';
}
static String _formatEpoch(int epoch) {
final seconds = epoch > 9999999999 ? epoch ~/ 1000 : epoch;
return DateFormat(
'yyyy-MM-dd HH:mm',
).format(DateTime.fromMillisecondsSinceEpoch(seconds * 1000));
}
@override
bool operator ==(Object other) =>
identical(this, other) || other is CloudFile && id == other.id;
@override
int get hashCode => id.hashCode;
}
+387
View File
@@ -0,0 +1,387 @@
import 'cloud_file.dart';
enum TMDBMediaKind { automatic, movie, tv }
extension TMDBMediaKindX on TMDBMediaKind {
String get title {
switch (this) {
case TMDBMediaKind.automatic:
return '自动识别';
case TMDBMediaKind.movie:
return '电影';
case TMDBMediaKind.tv:
return '剧集';
}
}
}
enum MediaLibraryKind { movies, series, mixed }
extension MediaLibraryKindX on MediaLibraryKind {
String get title {
switch (this) {
case MediaLibraryKind.movies:
return '电影';
case MediaLibraryKind.series:
return '电视剧';
case MediaLibraryKind.mixed:
return '混合内容';
}
}
}
class MediaLibrarySource {
final String id;
final String? rootID;
final String path;
const MediaLibrarySource({
required this.id,
required this.rootID,
required this.path,
});
factory MediaLibrarySource.fromJson(Map<String, dynamic> json) {
return MediaLibrarySource(
id:
json['id']?.toString() ??
DateTime.now().microsecondsSinceEpoch.toString(),
rootID: json['rootID']?.toString(),
path: json['path']?.toString() ?? '未配置目录',
);
}
Map<String, dynamic> toJson() => {'id': id, 'rootID': rootID, 'path': path};
}
class MediaLibraryDefinition {
final String id;
final String name;
final List<MediaLibrarySource> sources;
final MediaLibraryKind kind;
final bool recursive;
final int minimumSizeMB;
final DateTime? updatedAt;
const MediaLibraryDefinition({
required this.id,
required this.name,
required this.sources,
this.kind = MediaLibraryKind.mixed,
this.recursive = true,
this.minimumSizeMB = 50,
this.updatedAt,
});
String? get rootID => sources.isEmpty ? null : sources.first.rootID;
String get rootPath {
if (sources.length == 1) return sources.first.path;
if (sources.isEmpty) return '未配置目录';
return '${sources.length} 个媒体目录';
}
MediaLibraryDefinition copyWith({
String? name,
List<MediaLibrarySource>? sources,
MediaLibraryKind? kind,
bool? recursive,
int? minimumSizeMB,
DateTime? updatedAt,
}) {
return MediaLibraryDefinition(
id: id,
name: name ?? this.name,
sources: sources ?? this.sources,
kind: kind ?? this.kind,
recursive: recursive ?? this.recursive,
minimumSizeMB: minimumSizeMB ?? this.minimumSizeMB,
updatedAt: updatedAt ?? this.updatedAt,
);
}
factory MediaLibraryDefinition.fromJson(Map<String, dynamic> json) {
final rawSources = json['sources'];
final sources = rawSources is List
? rawSources
.whereType<Map>()
.map(
(item) => MediaLibrarySource.fromJson(
Map<String, dynamic>.from(item),
),
)
.toList()
: [
MediaLibrarySource(
id: '${json['id'] ?? DateTime.now().microsecondsSinceEpoch}-legacy',
rootID: json['rootID']?.toString(),
path: json['rootPath']?.toString() ?? '未配置目录',
),
];
return MediaLibraryDefinition(
id:
json['id']?.toString() ??
DateTime.now().microsecondsSinceEpoch.toString(),
name: json['name']?.toString() ?? '未命名媒体库',
sources: sources,
kind: MediaLibraryKind.values.firstWhere(
(kind) => kind.name == json['kind']?.toString(),
orElse: () => MediaLibraryKind.mixed,
),
recursive: json['recursive'] != false,
minimumSizeMB: _toInt(json['minimumSizeMB']) ?? 50,
updatedAt: _parseDate(json['updatedAt']),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'sources': sources.map((source) => source.toJson()).toList(),
'kind': kind.name,
'recursive': recursive,
'minimumSizeMB': minimumSizeMB,
'updatedAt': updatedAt?.toIso8601String(),
};
}
class MediaLibraryItem {
final String libraryID;
final CloudFile file;
final int? tmdbID;
final String title;
final String originalTitle;
final TMDBMediaKind? mediaKind;
final String releaseDate;
final String overview;
final String? posterPath;
final String? backdropPath;
final bool hasChineseAudio;
final bool hasChineseSubtitle;
final int? collectionID;
final String? collectionName;
final DateTime updatedAt;
const MediaLibraryItem({
required this.libraryID,
required this.file,
this.tmdbID,
required this.title,
required this.originalTitle,
this.mediaKind,
this.releaseDate = '',
this.overview = '',
this.posterPath,
this.backdropPath,
this.hasChineseAudio = false,
this.hasChineseSubtitle = false,
this.collectionID,
this.collectionName,
required this.updatedAt,
});
String get id => file.id;
String get year => releaseDate.length >= 4 ? releaseDate.substring(0, 4) : '';
bool get isMatched => mediaKind != null && tmdbID != null;
factory MediaLibraryItem.fromFile(String libraryID, CloudFile file) {
final parsed = ParsedMediaName.parse(file.name);
final kind = parsed.isEpisode ? TMDBMediaKind.tv : TMDBMediaKind.movie;
return MediaLibraryItem(
libraryID: libraryID,
file: file,
title: parsed.title,
originalTitle: parsed.title,
mediaKind: kind,
releaseDate: parsed.year == null ? '' : '${parsed.year}-01-01',
updatedAt: DateTime.now(),
);
}
factory MediaLibraryItem.fromJson(Map<String, dynamic> json) {
return MediaLibraryItem(
libraryID: json['libraryID']?.toString() ?? '',
file: CloudFile(
id: json['fileID']?.toString() ?? '',
name: json['cloudName']?.toString() ?? '',
isDirectory: false,
size: _toInt(json['fileSize']),
gcid: json['gcid']?.toString(),
modifiedAt: json['modifiedAt']?.toString() ?? '',
cloudPath: json['resourcePath']?.toString() ?? '',
fileType: _toInt(json['fileType']) ?? 2,
),
tmdbID: _toInt(json['tmdbID']),
title: json['title']?.toString() ?? '',
originalTitle: json['originalTitle']?.toString() ?? '',
mediaKind: TMDBMediaKind.values
.where((kind) => kind.name == json['mediaKind'])
.firstOrNull,
releaseDate: json['releaseDate']?.toString() ?? '',
overview: json['overview']?.toString() ?? '',
posterPath: json['posterPath']?.toString(),
backdropPath: json['backdropPath']?.toString(),
hasChineseAudio: json['hasChineseAudio'] == true,
hasChineseSubtitle: json['hasChineseSubtitle'] == true,
collectionID: _toInt(json['collectionID']),
collectionName: json['collectionName']?.toString(),
updatedAt: _parseDate(json['updatedAt']) ?? DateTime.now(),
);
}
Map<String, dynamic> toJson() => {
'libraryID': libraryID,
'fileID': file.id,
'resourcePath': file.cloudPath,
'cloudName': file.name,
'fileSize': file.size,
'gcid': file.gcid,
'fileType': file.fileType,
'modifiedAt': file.modifiedAt,
'tmdbID': tmdbID,
'mediaKind': mediaKind?.name,
'title': title,
'originalTitle': originalTitle,
'releaseDate': releaseDate,
'overview': overview,
'posterPath': posterPath,
'backdropPath': backdropPath,
'hasChineseAudio': hasChineseAudio,
'hasChineseSubtitle': hasChineseSubtitle,
'collectionID': collectionID,
'collectionName': collectionName,
'updatedAt': updatedAt.toIso8601String(),
};
}
class MediaLibraryStatistics {
final int total;
final int movies;
final int series;
final int unmatched;
final int collections;
const MediaLibraryStatistics({
this.total = 0,
this.movies = 0,
this.series = 0,
this.unmatched = 0,
this.collections = 0,
});
factory MediaLibraryStatistics.fromItems(Iterable<MediaLibraryItem> items) {
var total = 0;
var movies = 0;
var series = 0;
var unmatched = 0;
final collections = <String>{};
for (final item in items) {
total++;
if (item.mediaKind == TMDBMediaKind.movie) movies++;
if (item.mediaKind == TMDBMediaKind.tv) series++;
if (!item.isMatched) unmatched++;
final collectionKey =
item.collectionID?.toString() ?? item.collectionName;
if (collectionKey != null && collectionKey.isNotEmpty) {
collections.add(collectionKey);
}
}
return MediaLibraryStatistics(
total: total,
movies: movies,
series: series,
unmatched: unmatched,
collections: collections.length,
);
}
}
class MediaLibraryScanProgress {
final String phase;
final int completed;
final int total;
const MediaLibraryScanProgress({
this.phase = '',
this.completed = 0,
this.total = 0,
});
}
class ParsedMediaName {
final String title;
final int? year;
final int? season;
final int? episode;
final bool isEpisode;
const ParsedMediaName({
required this.title,
this.year,
this.season,
this.episode,
this.isEpisode = false,
});
factory ParsedMediaName.parse(String name) {
var stem = name.replaceFirst(RegExp(r'\.[^.]+$'), '');
stem = stem.replaceAll(RegExp(r'[\._]+'), ' ');
final episodeMatch =
RegExp(
r'(?:S|第)\s*(\d{1,2})\s*(?:E|季\s*第?)\s*(\d{1,3})',
caseSensitive: false,
).firstMatch(stem) ??
RegExp(r'(\d{1,2})x(\d{1,3})', caseSensitive: false).firstMatch(stem);
final yearMatch = RegExp(r'(19\d{2}|20\d{2})').firstMatch(stem);
final cutIndex =
[
episodeMatch?.start,
yearMatch?.start,
RegExp(
r'\b(2160p|1080p|720p|bluray|web[- ]?dl|hdtv|x264|x265|hevc)\b',
caseSensitive: false,
).firstMatch(stem)?.start,
].whereType<int>().fold<int?>(
null,
(min, value) => min == null || value < min ? value : min,
);
var title = cutIndex == null ? stem : stem.substring(0, cutIndex);
title = title
.replaceAll(RegExp(r'[\[\]\(\)]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (title.isEmpty) title = stem.trim();
return ParsedMediaName(
title: title,
year: yearMatch == null ? null : int.tryParse(yearMatch.group(1)!),
season: episodeMatch == null
? null
: int.tryParse(episodeMatch.group(1)!),
episode: episodeMatch == null
? null
: int.tryParse(episodeMatch.group(2)!),
isEpisode: episodeMatch != null,
);
}
}
int? _toInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is double) return value.toInt();
return int.tryParse(value.toString());
}
DateTime? _parseDate(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}
+330
View File
@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../providers/auth_provider.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _phoneController = TextEditingController(text: '+86 ');
final _codeController = TextEditingController();
String _activeTab = 'sms';
@override
void dispose() {
_phoneController.dispose();
_codeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final auth = ref.watch(authProvider);
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primary.withAlpha(30),
theme.colorScheme.background,
theme.colorScheme.primary.withAlpha(15),
],
),
),
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withAlpha(80),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Icon(
LucideIcons.cloud,
size: 48,
color: theme.colorScheme.primaryForeground,
),
),
const SizedBox(height: 20),
Text(
'光鸭云盘',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: theme.colorScheme.foreground,
),
),
const SizedBox(height: 8),
Text(
'登录以访问您的云端文件',
style: TextStyle(
fontSize: 15,
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 32),
// Login card
ShadCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTabs(context),
const SizedBox(height: 24),
if (_activeTab == 'sms')
_buildSMSLogin(context, auth)
else
_buildQRLogin(context, auth),
],
),
),
),
],
),
),
),
),
),
);
}
Widget _buildTabs(BuildContext context) {
final theme = ShadTheme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.muted,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(4),
child: Row(
children: [
Expanded(
child: _TabButton(
label: '手机登录',
isActive: _activeTab == 'sms',
onTap: () => setState(() => _activeTab = 'sms'),
),
),
Expanded(
child: _TabButton(
label: '扫码登录',
isActive: _activeTab == 'qr',
onTap: () {
setState(() => _activeTab = 'qr');
if (_activeTab == 'qr') {
ref.read(authProvider.notifier).initQRLogin();
}
},
),
),
],
),
);
}
Widget _buildSMSLogin(BuildContext context, AuthState auth) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ShadInput(
controller: _phoneController,
placeholder: const Text('手机号'),
leading: Icon(
LucideIcons.phone,
size: 16,
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ShadInput(
controller: _codeController,
placeholder: const Text('验证码'),
leading: Icon(
LucideIcons.keyRound,
size: 16,
color: theme.colorScheme.mutedForeground,
),
),
),
const SizedBox(width: 12),
ShadButton.outline(
onPressed: auth.codeCountdown > 0
? null
: () {
ref.read(authProvider.notifier).updatePhoneNumber(
_phoneController.text);
ref.read(authProvider.notifier).sendVerificationCode();
},
child: Text(
auth.codeCountdown > 0
? '${auth.codeCountdown}s'
: '获取验证码',
),
),
],
),
if (auth.errorMessage != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.destructive.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.destructive.withAlpha(50),
),
),
child: Text(
auth.errorMessage!,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.destructive,
),
),
),
],
const SizedBox(height: 24),
ShadButton(
onPressed: () {
ref.read(authProvider.notifier).updatePhoneNumber(
_phoneController.text);
ref.read(authProvider.notifier).updateVerificationCode(
_codeController.text);
ref.read(authProvider.notifier).verifySMSCode();
},
child: const Text('登录'),
),
],
);
}
Widget _buildQRLogin(BuildContext context, AuthState auth) {
final theme = ShadTheme.of(context);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (auth.qrPayload.isNotEmpty)
Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: theme.colorScheme.background,
border: Border.all(color: theme.colorScheme.border),
borderRadius: BorderRadius.circular(12),
),
child: QrImageView(
data: auth.qrPayload,
version: QrVersions.auto,
size: 180,
backgroundColor: Colors.transparent,
),
)
else
const SizedBox(
width: 200,
height: 200,
child: Center(child: ShadProgress()),
),
const SizedBox(height: 16),
Text(
auth.qrStatus,
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(height: 16),
ShadButton.outline(
onPressed: () => ref.read(authProvider.notifier).initQRLogin(),
leading: const Icon(LucideIcons.refreshCw, size: 16),
child: const Text('刷新二维码'),
),
],
);
}
}
class _TabButton extends StatelessWidget {
final String label;
final bool isActive;
final VoidCallback onTap;
const _TabButton({
required this.label,
required this.isActive,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isActive ? theme.colorScheme.background : Colors.transparent,
borderRadius: BorderRadius.circular(6),
boxShadow: isActive
? [
BoxShadow(
color: Colors.black.withAlpha(20),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
color: isActive
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground,
),
),
),
);
}
}
+789
View File
@@ -0,0 +1,789 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../core/storage/storage_manager.dart';
import '../models/media_library.dart';
import '../providers/auth_provider.dart';
import '../providers/file_provider.dart';
import '../providers/media_library_provider.dart';
class MediaLibraryPage extends ConsumerStatefulWidget {
final bool showLibrarySidebar;
final String? searchTitle;
const MediaLibraryPage({
super.key,
this.showLibrarySidebar = true,
this.searchTitle,
});
static void showCreateDialog(BuildContext context, WidgetRef ref) {
_MediaLibraryPageState._showCreateLibraryDialog(context, ref);
}
@override
ConsumerState<MediaLibraryPage> createState() => _MediaLibraryPageState();
}
class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
String _tmdbApiKey = StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
bool _showApiKeyInput = false;
bool _tmdbSearching = false;
String? _tmdbError;
List<Map<String, dynamic>> _tmdbResults = [];
final _apiKeyController = TextEditingController();
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_apiKeyController.text = _tmdbApiKey;
Future.microtask(() {
ref.read(mediaLibraryProvider.notifier).api = ref
.read(authProvider.notifier)
.api;
ref.read(mediaLibraryProvider.notifier).load();
});
}
@override
void dispose() {
_apiKeyController.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(mediaLibraryProvider);
return Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
child: Column(
children: [
_buildHeader(context, state),
const SizedBox(height: 12),
_buildToolbar(context, state),
const SizedBox(height: 12),
if (state.errorMessage != null || state.statusMessage != null)
_buildMessageBar(context, state),
Expanded(
child: widget.showLibrarySidebar
? Row(
children: [
_buildLibraryList(context, state),
VerticalDivider(
width: 24,
color: ShadTheme.of(context).colorScheme.border,
),
Expanded(child: _buildMainPanel(context, state)),
],
)
: _buildMainPanel(context, state),
),
],
),
);
}
Widget _buildHeader(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
final stats = state.statistics;
return Row(
children: [
Icon(Icons.movie_filter_rounded, size: 26, color: cs.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.searchTitle ?? '光鸭影视',
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
Text(
state.selectedLibrary?.name ?? '创建媒体库后扫描云盘影视文件',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
overflow: TextOverflow.ellipsis,
),
],
),
),
_statPill(context, '全部', stats.total.toString()),
_statPill(context, '电影', stats.movies.toString()),
_statPill(context, '剧集', stats.series.toString()),
_statPill(context, '待匹配', stats.unmatched.toString()),
],
);
}
Widget _statPill(BuildContext context, String label, String value) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: cs.muted,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.border),
),
child: Text(
'$label $value',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
);
}
Widget _buildToolbar(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
return Row(
children: [
Expanded(
child: ShadInput(
controller: _searchController,
placeholder: const Text('搜索影视库或 TMDB…'),
leading: Icon(
Icons.search_rounded,
size: 16,
color: cs.mutedForeground,
),
onChanged: (value) =>
ref.read(mediaLibraryProvider.notifier).setSearchQuery(value),
onSubmitted: _searchTMDB,
),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: () => _showCreateLibraryDialog(context, ref),
leading: const Icon(Icons.add_rounded, size: 16),
child: const Text('媒体库'),
),
const SizedBox(width: 8),
ShadButton.outline(
onPressed: state.selectedLibrary == null || state.isScanning
? null
: () => ref
.read(mediaLibraryProvider.notifier)
.scanSelectedLibrary(),
leading: const Icon(Icons.refresh_rounded, size: 16),
child: const Text('扫描'),
),
if (state.isScanning) ...[
const SizedBox(width: 8),
ShadButton.destructive(
onPressed: () =>
ref.read(mediaLibraryProvider.notifier).cancelScan(),
leading: const Icon(Icons.stop_rounded, size: 16),
child: const Text('停止'),
),
],
const SizedBox(width: 8),
ShadButton.outline(
onPressed: () => setState(() => _showApiKeyInput = !_showApiKeyInput),
leading: Icon(
_tmdbApiKey.isEmpty ? Icons.key_off_rounded : Icons.key_rounded,
size: 16,
),
child: const Text('TMDB'),
),
const SizedBox(width: 8),
ShadButton(
onPressed: _tmdbApiKey.isEmpty
? null
: () => _searchTMDB(_searchController.text),
leading: const Icon(Icons.travel_explore_rounded, size: 16),
child: const Text('匹配'),
),
],
);
}
Widget _buildMessageBar(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
final isError = state.errorMessage != null;
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isError ? cs.destructive.withValues(alpha: 0.08) : cs.muted,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: isError ? cs.destructive : cs.border),
),
child: Text(
state.errorMessage ?? state.statusMessage ?? '',
style: TextStyle(
fontSize: 12,
color: isError ? cs.destructive : cs.mutedForeground,
),
),
);
}
Widget _buildLibraryList(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
return SizedBox(
width: 260,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_showApiKeyInput) _buildTMDBConfig(context),
Text(
'媒体库',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: cs.mutedForeground,
),
),
const SizedBox(height: 8),
Expanded(
child: state.libraries.isEmpty
? _emptyLibraryHint(context)
: ListView.builder(
itemCount: state.libraries.length,
itemBuilder: (context, index) {
final library = state.libraries[index];
final selected = library.id == state.selectedLibrary?.id;
return _LibraryRow(
library: library,
selected: selected,
onTap: () => ref
.read(mediaLibraryProvider.notifier)
.selectLibrary(library.id),
onDelete: () => ref
.read(mediaLibraryProvider.notifier)
.deleteLibrary(library.id),
);
},
),
),
],
),
);
}
Widget _buildTMDBConfig(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
ShadInput(
controller: _apiKeyController,
placeholder: const Text('TMDB API Key'),
obscureText: true,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ShadButton(onPressed: _saveApiKey, child: const Text('保存')),
),
],
),
);
}
Widget _emptyLibraryHint(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.video_library_outlined,
size: 42,
color: cs.mutedForeground,
),
const SizedBox(height: 12),
Text(
'暂无媒体库',
style: TextStyle(fontSize: 14, color: cs.mutedForeground),
),
const SizedBox(height: 10),
ShadButton.outline(
onPressed: () => _showCreateLibraryDialog(context, ref),
child: const Text('创建媒体库'),
),
],
),
);
}
Widget _buildMainPanel(BuildContext context, MediaLibraryState state) {
if (state.isLoading) {
return const Center(child: ShadProgress());
}
if (state.isScanning) {
return _scanProgress(context, state);
}
if (_tmdbSearching || _tmdbResults.isNotEmpty || _tmdbError != null) {
return _tmdbResultPanel(context);
}
final items = state.visibleItems;
if (state.selectedLibrary == null) {
return _mainEmpty(context, '还没有媒体库', '从云盘根目录或当前目录创建一个媒体库');
}
if (items.isEmpty) {
return _mainEmpty(context, '没有扫描结果', '点击扫描读取该媒体库下的视频文件');
}
return LayoutBuilder(
builder: (context, constraints) {
final columns = (constraints.maxWidth / 220).floor().clamp(2, 6);
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.8,
),
itemCount: items.length,
itemBuilder: (context, index) => _MediaItemTile(item: items[index]),
);
},
);
}
Widget _scanProgress(BuildContext context, MediaLibraryState state) {
final cs = ShadTheme.of(context).colorScheme;
return Center(
child: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ShadProgress(),
const SizedBox(height: 16),
Text(
state.progress.phase,
textAlign: TextAlign.center,
style: TextStyle(color: cs.foreground),
),
const SizedBox(height: 6),
Text(
'已发现 ${state.progress.completed} 个视频文件',
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
],
),
),
);
}
Widget _mainEmpty(BuildContext context, String title, String subtitle) {
final cs = ShadTheme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.movie_creation_outlined,
size: 56,
color: cs.mutedForeground,
),
const SizedBox(height: 14),
Text(title, style: TextStyle(fontSize: 16, color: cs.foreground)),
const SizedBox(height: 6),
Text(
subtitle,
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
],
),
);
}
Widget _tmdbResultPanel(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
if (_tmdbSearching) return const Center(child: ShadProgress());
if (_tmdbError != null) {
return _mainEmpty(context, 'TMDB 请求失败', _tmdbError!);
}
if (_tmdbResults.isEmpty) {
return _mainEmpty(context, '没有 TMDB 结果', '换一个片名继续搜索');
}
return ListView.separated(
itemCount: _tmdbResults.length,
separatorBuilder: (context, index) =>
Divider(color: cs.border, height: 1),
itemBuilder: (context, index) =>
_buildTMDBResultItem(context, _tmdbResults[index]),
);
}
Widget _buildTMDBResultItem(BuildContext context, Map<String, dynamic> item) {
final cs = ShadTheme.of(context).colorScheme;
final title = item['title'] ?? item['name'] ?? '未知';
final overview = item['overview']?.toString() ?? '';
final releaseDate =
item['release_date']?.toString() ??
item['first_air_date']?.toString() ??
'';
final mediaType = item['media_type']?.toString() ?? 'movie';
final posterPath = item['poster_path'] as String?;
final year = releaseDate.length >= 4 ? releaseDate.substring(0, 4) : '';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: posterPath == null
? _posterPlaceholder(context, 74, 110)
: Image.network(
'https://image.tmdb.org/t/p/w200$posterPath',
width: 74,
height: 110,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_posterPlaceholder(context, 74, 110),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
year.isEmpty ? title.toString() : '$title ($year)',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: cs.foreground,
),
),
),
ShadBadge(child: Text(mediaType == 'tv' ? '剧集' : '电影')),
],
),
const SizedBox(height: 6),
Text(
overview.isEmpty ? '暂无简介' : overview,
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
Widget _posterPlaceholder(BuildContext context, double width, double height) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
width: width,
height: height,
color: cs.muted,
child: Icon(Icons.movie_rounded, color: cs.mutedForeground),
);
}
static void _showCreateLibraryDialog(BuildContext context, WidgetRef ref) {
final fileState = ref.read(fileProvider);
final currentRootID = fileState.folderPath.isEmpty
? null
: fileState.folderPath.last.id;
final currentPath = fileState.folderPath.isEmpty
? '云盘根目录'
: fileState.folderPath.map((file) => file.name).join(' / ');
final nameController = TextEditingController(
text: fileState.folderPath.isEmpty
? '我的影视库'
: fileState.folderPath.last.name,
);
final minSizeController = TextEditingController(text: '50');
var kind = MediaLibraryKind.mixed;
var recursive = true;
showShadDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setDialogState) => ShadDialog(
title: const Text('创建媒体库'),
description: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text('来源:$currentPath'),
),
actions: [
ShadButton.outline(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
ShadButton(
onPressed: () {
ref
.read(mediaLibraryProvider.notifier)
.createLibrary(
name: nameController.text,
rootID: currentRootID,
rootPath: currentPath,
kind: kind,
recursive: recursive,
minimumSizeMB:
int.tryParse(minSizeController.text.trim()) ?? 50,
);
Navigator.of(ctx).pop();
},
child: const Text('创建'),
),
],
child: Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ShadInput(
controller: nameController,
placeholder: const Text('媒体库名称'),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: DropdownButtonFormField<MediaLibraryKind>(
initialValue: kind,
decoration: const InputDecoration(
labelText: '类型',
border: OutlineInputBorder(),
),
items: MediaLibraryKind.values
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(value.title),
),
)
.toList(),
onChanged: (value) {
if (value != null) {
setDialogState(() => kind = value);
}
},
),
),
const SizedBox(width: 10),
SizedBox(
width: 120,
child: ShadInput(
controller: minSizeController,
placeholder: const Text('最小 MB'),
),
),
],
),
const SizedBox(height: 10),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: recursive,
onChanged: (value) =>
setDialogState(() => recursive = value ?? true),
title: const Text('递归扫描子目录'),
controlAffinity: ListTileControlAffinity.leading,
),
],
),
),
),
),
);
}
void _saveApiKey() {
final key = _apiKeyController.text.trim();
StorageManager.set(StorageKeys.tmdbApiKey, key);
setState(() {
_tmdbApiKey = key;
_showApiKeyInput = false;
});
}
Future<void> _searchTMDB(String query) async {
final text = query.trim();
if (text.isEmpty || _tmdbApiKey.isEmpty) return;
setState(() {
_tmdbSearching = true;
_tmdbError = null;
_tmdbResults = [];
});
try {
final api = ref.read(authProvider.notifier).api;
final result = await api.tmdbSearch(text, apiKey: _tmdbApiKey);
final results =
(result['results'] as List?)
?.whereType<Map>()
.where(
(item) =>
item['media_type'] == 'movie' || item['media_type'] == 'tv',
)
.map((item) => Map<String, dynamic>.from(item))
.toList() ??
[];
setState(() {
_tmdbResults = results;
_tmdbSearching = false;
});
} catch (e) {
setState(() {
_tmdbError = e.toString();
_tmdbSearching = false;
});
}
}
}
class _LibraryRow extends StatelessWidget {
final MediaLibraryDefinition library;
final bool selected;
final VoidCallback onTap;
final VoidCallback onDelete;
const _LibraryRow({
required this.library,
required this.selected,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: selected
? cs.primary.withValues(alpha: 0.08)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selected ? cs.primary : cs.border),
),
child: ListTile(
dense: true,
leading: Icon(
Icons.video_library_rounded,
size: 20,
color: selected ? cs.primary : cs.mutedForeground,
),
title: Text(
library.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: cs.foreground,
),
),
subtitle: Text(
'${library.kind.title} · ${library.rootPath}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
),
trailing: PopupMenuButton<String>(
tooltip: '更多',
icon: Icon(
Icons.more_horiz_rounded,
size: 18,
color: cs.mutedForeground,
),
onSelected: (value) {
if (value == 'delete') onDelete();
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
onTap: onTap,
),
);
}
}
class _MediaItemTile extends StatelessWidget {
final MediaLibraryItem item;
const _MediaItemTile({required this.item});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: cs.card,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.border),
),
child: Row(
children: [
Container(
width: 54,
height: 76,
decoration: BoxDecoration(
color: cs.muted,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.movie_rounded,
size: 24,
color: cs.mutedForeground,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.year.isEmpty
? item.title
: '${item.title} (${item.year})',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 6),
Text(
item.file.formattedSize,
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
),
const SizedBox(height: 2),
Text(
item.file.cloudPath,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: cs.mutedForeground),
),
],
),
),
],
),
);
}
}
+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../providers/theme_provider.dart';
import '../core/storage/storage_manager.dart';
class SettingsDialog extends ConsumerStatefulWidget {
const SettingsDialog({super.key});
@override
ConsumerState<SettingsDialog> createState() => _SettingsDialogState();
}
class _SettingsDialogState extends ConsumerState<SettingsDialog> {
final _tmdbApiKeyController = TextEditingController();
final _tmdbProxyHostController = TextEditingController();
final _tmdbProxyPortController = TextEditingController();
@override
void initState() {
super.initState();
_tmdbApiKeyController.text =
StorageManager.get<String>(StorageKeys.tmdbApiKey) ?? '';
_tmdbProxyHostController.text =
StorageManager.get<String>(StorageKeys.tmdbProxyHost) ?? '';
_tmdbProxyPortController.text =
StorageManager.get<String>(StorageKeys.tmdbProxyPort) ?? '';
}
@override
void dispose() {
_tmdbApiKeyController.dispose();
_tmdbProxyHostController.dispose();
_tmdbProxyPortController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final themeState = ref.watch(themeProvider);
return ShadDialog(
title: const Text('设置'),
description: const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text('自定义应用外观和行为'),
),
actions: [
ShadButton(
child: const Text('完成'),
onPressed: () {
_saveSettings();
Navigator.of(context).pop();
},
),
],
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('外观',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: cs.foreground)),
const SizedBox(height: 12),
_SettingsRow(
icon: Icons.light_mode_rounded,
label: '主题模式',
child: ShadSelect<String>(
initialValue: _themeModeToString(themeState.themeMode),
minWidth: 160,
placeholder: const Text('选择主题'),
options: [
ShadOption(
value: 'light',
child: const Row(children: [
Icon(Icons.light_mode_rounded, size: 14),
SizedBox(width: 8),
Text('浅色')
])),
ShadOption(
value: 'dark',
child: const Row(children: [
Icon(Icons.dark_mode_rounded, size: 14),
SizedBox(width: 8),
Text('深色')
])),
ShadOption(
value: 'system',
child: const Row(children: [
Icon(Icons.brightness_auto_rounded, size: 14),
SizedBox(width: 8),
Text('跟随系统')
])),
],
selectedOptionBuilder: (ctx, value) =>
Text(_themeModeToString(_stringToThemeMode(value))),
onChanged: (value) {
if (value != null) {
ref
.read(themeProvider.notifier)
.setThemeMode(_stringToThemeMode(value));
}
},
),
),
const SizedBox(height: 16),
const ShadSeparator.horizontal(),
const SizedBox(height: 16),
Text('TMDB 配置',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: cs.foreground)),
const SizedBox(height: 12),
_SettingsRow(
icon: Icons.key_rounded,
label: 'API Key',
child: SizedBox(
width: 200,
child: ShadInput(
controller: _tmdbApiKeyController,
placeholder: const Text('输入 TMDB API Key'),
),
),
),
const SizedBox(height: 8),
_SettingsRow(
icon: Icons.dns_rounded,
label: '代理地址',
child: SizedBox(
width: 200,
child: ShadInput(
controller: _tmdbProxyHostController,
placeholder: const Text('例: 127.0.0.1'),
),
),
),
const SizedBox(height: 8),
_SettingsRow(
icon: Icons.numbers_rounded,
label: '代理端口',
child: SizedBox(
width: 200,
child: ShadInput(
controller: _tmdbProxyPortController,
placeholder: const Text('例: 7890'),
),
),
),
const SizedBox(height: 16),
const ShadSeparator.horizontal(),
const SizedBox(height: 16),
Text('关于',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: cs.foreground)),
const SizedBox(height: 12),
_SettingsRow(
icon: Icons.cloud_rounded,
label: '版本',
child: Text('v1.0.0',
style: TextStyle(fontSize: 13, color: cs.mutedForeground)),
),
],
),
),
),
);
}
void _saveSettings() {
StorageManager.set(
StorageKeys.tmdbApiKey, _tmdbApiKeyController.text.trim());
StorageManager.set(
StorageKeys.tmdbProxyHost, _tmdbProxyHostController.text.trim());
StorageManager.set(
StorageKeys.tmdbProxyPort, _tmdbProxyPortController.text.trim());
}
String _themeModeToString(ThemeMode mode) {
switch (mode) {
case ThemeMode.light:
return 'light';
case ThemeMode.dark:
return 'dark';
case ThemeMode.system:
return 'system';
}
}
ThemeMode _stringToThemeMode(String value) {
switch (value) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
}
class _SettingsRow extends StatelessWidget {
final IconData icon;
final String label;
final Widget child;
const _SettingsRow(
{required this.icon, required this.label, required this.child});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, size: 18, color: cs.mutedForeground),
const SizedBox(width: 12),
Text(label,
style: TextStyle(fontSize: 14, color: cs.foreground)),
const Spacer(),
child,
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+351
View File
@@ -0,0 +1,351 @@
import 'dart:async';
import 'package:flutter_riverpod/legacy.dart';
import '../core/storage/storage_manager.dart';
import '../api/guangya_api.dart';
class AuthState {
final bool isSignedIn;
final bool isLoading;
final bool isRefreshing;
final Map<String, dynamic>? userInfo;
final String? errorMessage;
final String phoneNumber;
final String verificationCode;
final String captchaToken;
final String verificationID;
final int codeCountdown;
final String qrPayload;
final String qrToken;
final String qrStatus;
const AuthState({
this.isSignedIn = false,
this.isLoading = true,
this.isRefreshing = false,
this.userInfo,
this.errorMessage,
this.phoneNumber = '+86 ',
this.verificationCode = '',
this.captchaToken = '',
this.verificationID = '',
this.codeCountdown = 0,
this.qrPayload = '',
this.qrToken = '',
this.qrStatus = '等待生成二维码',
});
AuthState copyWith({
bool? isSignedIn,
bool? isLoading,
bool? isRefreshing,
Map<String, dynamic>? userInfo,
bool clearUserInfo = false,
String? errorMessage,
bool clearError = false,
String? phoneNumber,
String? verificationCode,
String? captchaToken,
String? verificationID,
int? codeCountdown,
String? qrPayload,
String? qrToken,
String? qrStatus,
}) {
return AuthState(
isSignedIn: isSignedIn ?? this.isSignedIn,
isLoading: isLoading ?? this.isLoading,
isRefreshing: isRefreshing ?? this.isRefreshing,
userInfo: clearUserInfo ? null : (userInfo ?? this.userInfo),
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
phoneNumber: phoneNumber ?? this.phoneNumber,
verificationCode: verificationCode ?? this.verificationCode,
captchaToken: captchaToken ?? this.captchaToken,
verificationID: verificationID ?? this.verificationID,
codeCountdown: codeCountdown ?? this.codeCountdown,
qrPayload: qrPayload ?? this.qrPayload,
qrToken: qrToken ?? this.qrToken,
qrStatus: qrStatus ?? this.qrStatus,
);
}
// ── Derived getters ────────────────────────────────────────────
String get userName =>
_findStringDeep(userInfo, ['nickname', 'name', 'username']) ?? '光鸭用户';
String get memberLevel =>
_findStringDeep(userInfo, ['vipName', 'memberName', 'memberLevelName']) ??
'普通会员';
int? get capacity =>
_findInt64Deep(userInfo, ['capacity', 'totalCapacity']);
int? get usedCapacity =>
_findInt64Deep(userInfo, ['usedCapacity', 'usedSpace']);
String get capacityText {
if (capacity == null) return '空间信息暂不可用';
return '${_formatBytes(usedCapacity ?? 0)} / ${_formatBytes(capacity!)}';
}
// ── Static helpers ─────────────────────────────────────────────
static String? _findStringDeep(
Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final v = json[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
for (final entry in json.entries) {
if (entry.value is Map<String, dynamic>) {
final found =
_findStringDeep(entry.value as Map<String, dynamic>, keys);
if (found != null) return found;
}
}
return null;
}
static int? _findInt64Deep(
Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final v = json[key];
if (v != null) return v is int ? v : int.tryParse(v.toString());
}
return null;
}
static String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
class AuthNotifier extends StateNotifier<AuthState> {
final GuangyaAPI _api = GuangyaAPI(
deviceID: StorageManager.get<String>(StorageKeys.deviceID),
);
Timer? _qrPollingTimer;
Timer? _countdownTimer;
GuangyaAPI get api => _api;
AuthNotifier() : super(const AuthState()) {
_api.accessToken = '';
tryRestoreSession();
}
Future<void> tryRestoreSession() async {
final access = StorageManager.get<String>(StorageKeys.accessToken) ?? '';
final refresh = StorageManager.get<String>(StorageKeys.refreshToken);
_api.accessToken = access;
_api.refreshTokenValue = refresh;
if (access.isNotEmpty || refresh != null) {
try {
await _api.refreshAccessToken();
await _saveTokens();
state = state.copyWith(isSignedIn: true);
await loadAccount();
} catch (_) {
state = state.copyWith(isSignedIn: false);
_api.clearTokens();
await _clearTokens();
}
}
state = state.copyWith(isLoading: false);
}
Future<void> loadAccount() async {
try {
final userInfo = await _api.userInfo();
state = state.copyWith(userInfo: userInfo);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
// ── SMS Login ─────────────────────────────────────────────────────
void updatePhoneNumber(String value) {
state = state.copyWith(phoneNumber: value);
}
void updateVerificationCode(String value) {
state = state.copyWith(verificationCode: value);
}
Future<void> sendVerificationCode() async {
state = state.copyWith(clearError: true);
final cleanPhone =
state.phoneNumber.replaceAll(RegExp(r'[^\d]'), '');
if (cleanPhone.length < 8) {
state = state.copyWith(errorMessage: '请输入有效的手机号');
return;
}
try {
final initResult = await _api.loginSMSInit(cleanPhone);
final captcha =
AuthState._findStringDeep(initResult, ['captcha_token', 'captchaToken']);
if (captcha == null) {
if (AuthState._findStringDeep(initResult, ['url', 'verify_url']) !=
null) {
throw Exception('需要完成验证码验证后再发送短信');
}
throw Exception('获取验证码令牌失败');
}
state = state.copyWith(captchaToken: captcha);
await _api.loginSMSSend(cleanPhone, captchaToken: captcha);
_startCountdown();
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
void _startCountdown() {
state = state.copyWith(codeCountdown: 60);
_countdownTimer?.cancel();
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final next = state.codeCountdown - 1;
if (next <= 0) {
timer.cancel();
state = state.copyWith(codeCountdown: 0);
} else {
state = state.copyWith(codeCountdown: next);
}
});
}
Future<void> verifySMSCode() async {
state = state.copyWith(clearError: true);
try {
final verifyResult = await _api.loginSMSVerify(
state.verificationID, state.verificationCode);
final vToken = AuthState._findStringDeep(
verifyResult, ['verification_token', 'verificationToken']);
final code =
AuthState._findStringDeep(verifyResult, ['code']);
if (vToken == null || code == null) throw Exception('验证码验证失败');
await _api.loginSMSSignIn(
code: code,
verificationToken: vToken,
username: state.phoneNumber.replaceAll(RegExp(r'[^\d]'), ''),
captchaToken: state.captchaToken,
);
state = state.copyWith(isSignedIn: true);
await _saveTokens();
await loadAccount();
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
// ── QR Login ──────────────────────────────────────────────────────
Future<void> initQRLogin() async {
state = state.copyWith(clearError: true);
try {
final result = await _api.loginQRInit();
final token = AuthState._findStringDeep(
result, ['device_code', 'deviceCode']) ??
'';
final payload = AuthState._findStringDeep(
result, ['verification_uri_complete', 'verificationUriComplete', 'qr_url', 'qrUrl', 'url', 'verification_uri', 'verificationUri', 'qrcode', 'qrCode', 'code', 'qr_payload', 'qrPayload']) ??
token;
state = state.copyWith(
qrPayload: payload,
qrToken: token,
qrStatus: '请使用光鸭APP扫描二维码',
);
_startQRPolling();
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
void _startQRPolling() {
_qrPollingTimer?.cancel();
_qrPollingTimer =
Timer.periodic(const Duration(seconds: 5), (timer) async {
if (state.qrToken.isEmpty) {
timer.cancel();
return;
}
try {
final result = await _api.loginQRPoll(state.qrToken);
final accessToken = AuthState._findStringDeep(
result, ['access_token', 'accessToken']);
if (accessToken != null) {
timer.cancel();
state = state.copyWith(isSignedIn: true);
await _saveTokens();
await loadAccount();
} else {
final error =
AuthState._findStringDeep(result, ['error']);
String? newStatus;
if (error == 'slow_down') {
newStatus = '轮询过快,请稍候';
} else if (error == 'authorization_pending') {
newStatus = '等待扫码确认…';
}
if (newStatus != null) {
state = state.copyWith(qrStatus: newStatus);
}
}
} catch (_) {
// Continue polling on transient errors
}
});
}
// ── Sign out ──────────────────────────────────────────────────────
Future<void> signOut() async {
_api.clearTokens();
await _clearTokens();
_qrPollingTimer?.cancel();
_countdownTimer?.cancel();
state = const AuthState(isLoading: false);
}
// ── Token persistence ─────────────────────────────────────────────
Future<void> _saveTokens() async {
await StorageManager.set(StorageKeys.accessToken, _api.accessToken);
if (_api.refreshTokenValue != null) {
await StorageManager.set(StorageKeys.refreshToken, _api.refreshTokenValue!);
}
if (_api.tokenExpiresAt != null) {
await StorageManager.set(
StorageKeys.tokenExpiresAt,
_api.tokenExpiresAt!.millisecondsSinceEpoch,
);
}
await StorageManager.set(StorageKeys.deviceID, _api.deviceID);
}
Future<void> _clearTokens() async {
await StorageManager.delete(StorageKeys.accessToken);
await StorageManager.delete(StorageKeys.refreshToken);
}
@override
void dispose() {
_qrPollingTimer?.cancel();
_countdownTimer?.cancel();
super.dispose();
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);
+597
View File
@@ -0,0 +1,597 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/legacy.dart';
import '../api/guangya_api.dart';
import '../models/cloud_file.dart';
enum FileSort { name, size, modifiedAt, createdAt, type }
extension FileSortExt on FileSort {
String get title {
switch (this) {
case FileSort.name:
return '名称';
case FileSort.size:
return '大小';
case FileSort.modifiedAt:
return '修改时间';
case FileSort.createdAt:
return '创建时间';
case FileSort.type:
return '类型';
}
}
int get apiOrderBy {
switch (this) {
case FileSort.name:
return 0;
case FileSort.size:
return 1;
case FileSort.createdAt:
return 2;
case FileSort.modifiedAt:
return 3;
case FileSort.type:
return 4;
}
}
}
enum SortDirection { ascending, descending }
enum WorkspaceSection {
files('全部', 'folder'),
recentViewed('最近查看', 'clock'),
recentRestored('最近转存', 'history'),
photos('图片', 'image'),
videos('视频', 'movie'),
audio('音频', 'music_note'),
documents('文档', 'description'),
cloud('云下载', 'cloud_download'),
shares('我的分享', 'share'),
recycle('回收站', 'delete'),
mediaLibrary('光鸭影视', 'movie_filter');
final String label;
final String icon;
const WorkspaceSection(this.label, this.icon);
}
class FileState {
final WorkspaceSection section;
final List<CloudFile> files;
final List<CloudFile> folderPath;
final bool isLoading;
final int currentPage;
final int pageSize;
final int totalPages;
final FileSort serverSort;
final SortDirection serverSortDirection;
final String? errorMessage;
final String? statusMessage;
final Set<String> selectedIDs;
final List<CloudFile>? clipboard;
final bool clipboardIsMove;
const FileState({
this.section = WorkspaceSection.files,
this.files = const [],
this.folderPath = const [],
this.isLoading = false,
this.currentPage = 0,
this.pageSize = 50,
this.totalPages = 1,
this.serverSort = FileSort.name,
this.serverSortDirection = SortDirection.ascending,
this.errorMessage,
this.statusMessage,
this.selectedIDs = const {},
this.clipboard,
this.clipboardIsMove = false,
});
bool get hasSelection => selectedIDs.isNotEmpty;
int get selectedCount => selectedIDs.length;
FileState copyWith({
WorkspaceSection? section,
List<CloudFile>? files,
List<CloudFile>? folderPath,
bool? isLoading,
int? currentPage,
int? pageSize,
int? totalPages,
FileSort? serverSort,
SortDirection? serverSortDirection,
String? errorMessage,
bool clearError = false,
String? statusMessage,
bool clearStatus = false,
Set<String>? selectedIDs,
List<CloudFile>? clipboard,
bool clearClipboard = false,
bool? clipboardIsMove,
}) {
return FileState(
section: section ?? this.section,
files: files ?? this.files,
folderPath: folderPath ?? this.folderPath,
isLoading: isLoading ?? this.isLoading,
currentPage: currentPage ?? this.currentPage,
pageSize: pageSize ?? this.pageSize,
totalPages: totalPages ?? this.totalPages,
serverSort: serverSort ?? this.serverSort,
serverSortDirection: serverSortDirection ?? this.serverSortDirection,
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
statusMessage: clearStatus ? null : (statusMessage ?? this.statusMessage),
selectedIDs: selectedIDs ?? this.selectedIDs,
clipboard: clearClipboard ? null : (clipboard ?? this.clipboard),
clipboardIsMove: clipboardIsMove ?? this.clipboardIsMove,
);
}
}
class FileNotifier extends StateNotifier<FileState> {
GuangyaAPI? _api;
FileNotifier() : super(const FileState());
set api(GuangyaAPI value) => _api = value;
String? get _currentParentID =>
state.folderPath.isNotEmpty ? state.folderPath.last.id : null;
void setSection(WorkspaceSection section) {
final preservePath = section == WorkspaceSection.mediaLibrary;
state = state.copyWith(
section: section,
files: [],
folderPath: preservePath ? state.folderPath : [],
currentPage: 0,
selectedIDs: {},
);
loadFiles();
}
Future<void> loadFiles({String? parentID}) async {
if (_api == null) return;
state = state.copyWith(isLoading: true, clearError: true);
try {
final result = await _fetchFiles(parentID ?? _currentParentID);
final extracted = _extractFiles(result);
final totalPages = _extractTotalPages(result, extracted.length);
state = state.copyWith(files: extracted, totalPages: totalPages);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
} finally {
state = state.copyWith(isLoading: false);
}
}
Future<Map<String, dynamic>> _fetchFiles(String? parentID) async {
switch (state.section) {
case WorkspaceSection.files:
return await _api!.fsFiles(
parentID: parentID,
page: state.currentPage,
pageSize: state.pageSize,
orderBy: state.serverSort.apiOrderBy,
sortType: state.serverSortDirection == SortDirection.ascending
? 0
: 1,
);
case WorkspaceSection.recentViewed:
return await _api!.recentViewed(pageSize: 100);
case WorkspaceSection.recentRestored:
return await _api!.recentRestored(pageSize: 100);
case WorkspaceSection.photos:
return await _api!.fsFiles(
parentID: '*',
orderBy: 3,
sortType: 1,
fileTypes: [1],
resType: 1,
);
case WorkspaceSection.videos:
return await _api!.fsFiles(
parentID: '*',
orderBy: 3,
sortType: 1,
fileTypes: [2],
resType: 1,
);
case WorkspaceSection.audio:
return await _api!.fsFiles(
parentID: '*',
orderBy: 3,
sortType: 1,
fileTypes: [3],
resType: 1,
needPlayRecord: true,
);
case WorkspaceSection.documents:
return await _api!.fsFiles(
parentID: '*',
orderBy: 3,
sortType: 1,
fileTypes: [4],
resType: 1,
);
case WorkspaceSection.cloud:
return await _api!.cloudTaskList();
case WorkspaceSection.shares:
return await _api!.shareUserList();
case WorkspaceSection.recycle:
return await _api!.fsFiles(orderBy: 10, dirType: 4);
case WorkspaceSection.mediaLibrary:
return {
'code': 0,
'data': {'list': <dynamic>[], 'total': 0},
};
}
}
Future<void> navigateToFolder(CloudFile folder) async {
final newPath = [...state.folderPath, folder];
state = state.copyWith(
folderPath: newPath,
currentPage: 0,
selectedIDs: {},
);
await loadFiles(parentID: folder.id);
}
Future<void> navigateBack() async {
if (state.folderPath.isEmpty) return;
final newPath = List<CloudFile>.from(state.folderPath)..removeLast();
state = state.copyWith(
folderPath: newPath,
currentPage: 0,
selectedIDs: {},
);
final parentID = newPath.isNotEmpty ? newPath.last.id : null;
await loadFiles(parentID: parentID);
}
void navigateToPathIndex(int index) {
final newPath = state.folderPath.sublist(0, index + 1);
state = state.copyWith(
folderPath: newPath,
currentPage: 0,
selectedIDs: {},
);
final parentID = index >= 0 ? newPath[index].id : null;
loadFiles(parentID: parentID);
}
void toggleSelection(String id) {
final newSelected = Set<String>.from(state.selectedIDs);
if (newSelected.contains(id)) {
newSelected.remove(id);
} else {
newSelected.add(id);
}
state = state.copyWith(selectedIDs: newSelected);
}
void selectAll() {
if (state.selectedIDs.length == state.files.length) {
state = state.copyWith(selectedIDs: {});
} else {
state = state.copyWith(selectedIDs: state.files.map((f) => f.id).toSet());
}
}
void clearSelection() {
state = state.copyWith(selectedIDs: {});
}
void setSort(FileSort sort) {
SortDirection direction;
if (state.serverSort == sort) {
direction = state.serverSortDirection == SortDirection.ascending
? SortDirection.descending
: SortDirection.ascending;
} else {
direction = SortDirection.ascending;
}
state = state.copyWith(
serverSort: sort,
serverSortDirection: direction,
currentPage: 0,
);
loadFiles(parentID: _currentParentID);
}
void nextPage() {
if (state.currentPage < state.totalPages - 1) {
state = state.copyWith(currentPage: state.currentPage + 1);
loadFiles(parentID: _currentParentID);
}
}
void prevPage() {
if (state.currentPage > 0) {
state = state.copyWith(currentPage: state.currentPage - 1);
loadFiles(parentID: _currentParentID);
}
}
void setPageSize(int size) {
state = state.copyWith(pageSize: size, currentPage: 0);
loadFiles(parentID: _currentParentID);
}
// ── File operations ─────────────────────────────────────────────
Future<void> createFolder(String name) async {
if (_api == null) return;
state = state.copyWith(statusMessage: '正在创建文件夹…');
try {
await _api!.fsCreateDir(name, parentID: _currentParentID);
state = state.copyWith(statusMessage: '文件夹已创建');
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString(), clearStatus: true);
}
}
Future<void> renameFile(CloudFile file, String newName) async {
if (_api == null) return;
try {
await _api!.fsRename(file.id, newName);
state = state.copyWith(statusMessage: '重命名成功');
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> deleteFiles(List<CloudFile> files) async {
if (_api == null) return;
state = state.copyWith(statusMessage: '正在删除…');
try {
await _api!.fsDelete(files.map((f) => f.id).toList());
state = state.copyWith(
statusMessage: '已删除 ${files.length} 个项目',
selectedIDs: {},
);
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> recycleFiles(List<CloudFile> files) async {
if (_api == null) return;
try {
await _api!.fsRecycle(files.map((f) => f.id).toList());
state = state.copyWith(statusMessage: '已移入回收站', selectedIDs: {});
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> clearRecycleBin() async {
if (_api == null) return;
try {
await _api!.fsClearRecycleBin();
state = state.copyWith(statusMessage: '回收站已清空');
await loadFiles();
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
void copyToClipboard(List<CloudFile> files) {
state = state.copyWith(clipboard: files, clipboardIsMove: false);
}
void cutToClipboard(List<CloudFile> files) {
state = state.copyWith(clipboard: files, clipboardIsMove: true);
}
Future<void> pasteFromClipboard() async {
if (_api == null || state.clipboard == null) return;
state = state.copyWith(
statusMessage: state.clipboardIsMove ? '正在移动…' : '正在复制…',
);
try {
final ids = state.clipboard!.map((f) => f.id).toList();
if (state.clipboardIsMove) {
await _api!.fsMove(ids, parentID: _currentParentID);
} else {
await _api!.fsCopy(ids, parentID: _currentParentID);
}
state = state.copyWith(statusMessage: '操作完成');
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
void clearClipboard() {
state = state.copyWith(clearClipboard: true);
}
Future<void> downloadFile(CloudFile file) async {
if (_api == null) return;
try {
final result = await _api!.downloadURL(file.id);
final url = _findStringDeep(result, [
'url',
'downloadUrl',
'download_url',
]);
if (url != null) {
state = state.copyWith(statusMessage: '下载链接: $url');
}
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> uploadLocalFiles(List<File> files, {String? parentID}) async {
if (_api == null || files.isEmpty) return;
final targetParentID = parentID ?? _currentParentID;
state = state.copyWith(statusMessage: '正在上传 ${files.length} 个文件…');
var completed = 0;
try {
for (final file in files) {
if (!await file.exists()) continue;
state = state.copyWith(
statusMessage: '正在上传 ${completed + 1}/${files.length}${file.uri.pathSegments.last}',
);
await _api!.fileUpload(file, parentID: targetParentID);
completed += 1;
}
state = state.copyWith(statusMessage: '已上传 $completed 个文件');
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
Future<void> moveFilesTo(List<CloudFile> files, {String? parentID}) async {
if (_api == null || files.isEmpty) return;
state = state.copyWith(statusMessage: '正在移动 ${files.length} 个项目…');
try {
await _api!.fsMove(files.map((file) => file.id).toList(), parentID: parentID);
state = state.copyWith(statusMessage: '移动完成', selectedIDs: {});
await loadFiles(parentID: _currentParentID);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
}
}
void clearError() {
state = state.copyWith(clearError: true);
}
void clearStatus() {
state = state.copyWith(clearStatus: true);
}
// ── Helpers ─────────────────────────────────────────────────────
List<CloudFile> _extractFiles(Map<String, dynamic> json) {
final result = <CloudFile>[];
final seen = <String>{};
void visit(dynamic value) {
if (value is Map) {
final map = Map<String, dynamic>.from(value);
try {
final file = CloudFile.fromJson(map);
if (seen.add(file.id)) result.add(file);
} catch (_) {}
for (final v in map.values) {
visit(v);
}
} else if (value is List) {
for (final v in value) {
visit(v);
}
}
}
final preferred = _findArrayDeep(json, const [
'list',
'files',
'fileList',
'file_list',
'items',
'records',
'rows',
'dataList',
'resList',
'resourceList',
]);
if (preferred != null) {
visit(preferred);
return result;
}
visit(json);
return result;
}
int _extractTotalPages(Map<String, dynamic> json, int itemCount) {
final explicitPages = _extractInt(json, [
'totalPages',
'pages',
'pageCount',
]);
if (explicitPages != null && explicitPages > 0) return explicitPages;
final totalItems = _extractInt(json, ['total', 'totalCount', 'count']);
if (totalItems == null || totalItems <= 0) {
return itemCount < state.pageSize ? 1 : state.currentPage + 2;
}
return (totalItems / state.pageSize).ceil().clamp(1, 1 << 31).toInt();
}
static List<dynamic>? _findArrayDeep(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final v = json[key];
if (v is List) return v;
}
const preferredKeys = ['data', 'result', 'payload'];
for (final key in preferredKeys) {
final v = json[key];
if (v is Map) {
final found = _findArrayDeep(Map<String, dynamic>.from(v), keys);
if (found != null) return found;
}
}
for (final entry in json.entries) {
if (preferredKeys.contains(entry.key)) continue;
final v = entry.value;
if (v is Map) {
final found = _findArrayDeep(Map<String, dynamic>.from(v), keys);
if (found != null) return found;
}
}
return null;
}
static int? _extractInt(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final v = json[key];
if (v != null) return v is int ? v : int.tryParse(v.toString());
}
for (final entry in json.entries) {
if (entry.value is Map<String, dynamic>) {
final found = _extractInt(entry.value as Map<String, dynamic>, keys);
if (found != null) return found;
}
}
return null;
}
static String? _findStringDeep(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final v = json[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
for (final entry in json.entries) {
if (entry.value is Map<String, dynamic>) {
final found = _findStringDeep(
entry.value as Map<String, dynamic>,
keys,
);
if (found != null) return found;
}
}
return null;
}
}
final fileProvider = StateNotifierProvider<FileNotifier, FileState>(
(ref) => FileNotifier(),
);
+453
View File
@@ -0,0 +1,453 @@
import 'dart:async';
import 'package:flutter_riverpod/legacy.dart';
import '../api/guangya_api.dart';
import '../core/storage/storage_manager.dart';
import '../models/cloud_file.dart';
import '../models/media_library.dart';
class MediaLibraryState {
final List<MediaLibraryDefinition> libraries;
final String? selectedLibraryID;
final List<MediaLibraryItem> items;
final bool isLoading;
final bool isScanning;
final MediaLibraryScanProgress progress;
final String searchQuery;
final String? errorMessage;
final String? statusMessage;
const MediaLibraryState({
this.libraries = const [],
this.selectedLibraryID,
this.items = const [],
this.isLoading = false,
this.isScanning = false,
this.progress = const MediaLibraryScanProgress(),
this.searchQuery = '',
this.errorMessage,
this.statusMessage,
});
MediaLibraryDefinition? get selectedLibrary {
for (final library in libraries) {
if (library.id == selectedLibraryID) return library;
}
return libraries.isEmpty ? null : libraries.first;
}
List<MediaLibraryItem> get visibleItems {
final query = searchQuery.trim().toLowerCase();
if (query.isEmpty) return items;
return items.where((item) {
return item.title.toLowerCase().contains(query) ||
item.file.name.toLowerCase().contains(query) ||
item.file.cloudPath.toLowerCase().contains(query);
}).toList();
}
MediaLibraryStatistics get statistics =>
MediaLibraryStatistics.fromItems(items);
MediaLibraryState copyWith({
List<MediaLibraryDefinition>? libraries,
String? selectedLibraryID,
bool clearSelectedLibrary = false,
List<MediaLibraryItem>? items,
bool? isLoading,
bool? isScanning,
MediaLibraryScanProgress? progress,
String? searchQuery,
String? errorMessage,
bool clearError = false,
String? statusMessage,
bool clearStatus = false,
}) {
return MediaLibraryState(
libraries: libraries ?? this.libraries,
selectedLibraryID: clearSelectedLibrary
? null
: (selectedLibraryID ?? this.selectedLibraryID),
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
isScanning: isScanning ?? this.isScanning,
progress: progress ?? this.progress,
searchQuery: searchQuery ?? this.searchQuery,
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
statusMessage: clearStatus ? null : (statusMessage ?? this.statusMessage),
);
}
}
class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
GuangyaAPI? _api;
bool _loaded = false;
bool _cancelScan = false;
MediaLibraryNotifier() : super(const MediaLibraryState());
set api(GuangyaAPI value) => _api = value;
Future<void> load() async {
if (_loaded) return;
_loaded = true;
state = state.copyWith(isLoading: true, clearError: true);
try {
final libraries = _loadLibraries();
final selectedID = libraries.isEmpty ? null : libraries.first.id;
final items = selectedID == null
? <MediaLibraryItem>[]
: _loadItems(selectedID);
state = state.copyWith(
libraries: libraries,
selectedLibraryID: selectedID,
items: items,
);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
} finally {
state = state.copyWith(isLoading: false);
}
}
Future<void> selectLibrary(String id) async {
state = state.copyWith(
selectedLibraryID: id,
items: _loadItems(id),
searchQuery: '',
clearError: true,
);
}
Future<void> createLibrary({
required String name,
required String? rootID,
required String rootPath,
MediaLibraryKind kind = MediaLibraryKind.mixed,
bool recursive = true,
int minimumSizeMB = 50,
}) async {
final trimmed = name.trim();
if (trimmed.isEmpty) return;
final now = DateTime.now();
final id = now.microsecondsSinceEpoch.toString();
final library = MediaLibraryDefinition(
id: id,
name: trimmed,
sources: [
MediaLibrarySource(id: '$id-source-0', rootID: rootID, path: rootPath),
],
kind: kind,
recursive: recursive,
minimumSizeMB: minimumSizeMB,
updatedAt: now,
);
final libraries = [...state.libraries, library];
await _saveLibraries(libraries);
state = state.copyWith(
libraries: libraries,
selectedLibraryID: library.id,
items: const [],
statusMessage: '已创建媒体库「${library.name}',
);
}
Future<void> deleteLibrary(String id) async {
final libraries = state.libraries
.where((library) => library.id != id)
.toList();
final allItems = _loadAllItems()
..removeWhere((item) => item.libraryID == id);
await _saveLibraries(libraries);
await _saveAllItems(allItems);
final selectedID = libraries.isEmpty ? null : libraries.first.id;
state = state.copyWith(
libraries: libraries,
selectedLibraryID: selectedID,
clearSelectedLibrary: selectedID == null,
items: selectedID == null ? const [] : _loadItems(selectedID),
statusMessage: '媒体库已删除',
);
}
Future<void> scanSelectedLibrary() async {
final library = state.selectedLibrary;
if (library == null || _api == null || state.isScanning) return;
_cancelScan = false;
state = state.copyWith(
isScanning: true,
progress: const MediaLibraryScanProgress(phase: '准备扫描'),
clearError: true,
clearStatus: true,
);
try {
final discovered = <CloudFile>[];
for (final source in library.sources) {
if (_cancelScan) break;
state = state.copyWith(
progress: MediaLibraryScanProgress(
phase: '扫描 ${source.path}',
completed: discovered.length,
),
);
final files = await _scanSource(
source.rootID,
source.path,
recursive: library.recursive,
minimumSizeBytes: library.minimumSizeMB * 1024 * 1024,
);
discovered.addAll(files);
}
final unique = <String, MediaLibraryItem>{};
for (final file in discovered) {
unique[file.id] = MediaLibraryItem.fromFile(library.id, file);
}
final items = unique.values.toList()
..sort(
(a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()),
);
final allItems = _loadAllItems()
..removeWhere((item) => item.libraryID == library.id)
..addAll(items);
final updatedLibrary = library.copyWith(updatedAt: DateTime.now());
final libraries = state.libraries
.map((item) => item.id == library.id ? updatedLibrary : item)
.toList();
await _saveAllItems(allItems);
await _saveLibraries(libraries);
state = state.copyWith(
libraries: libraries,
items: items,
statusMessage: _cancelScan
? '扫描已停止,已保留 ${items.length} 个项目'
: '扫描完成:${items.length} 个视频文件',
);
} catch (e) {
state = state.copyWith(errorMessage: e.toString());
} finally {
state = state.copyWith(
isScanning: false,
progress: const MediaLibraryScanProgress(),
);
}
}
void cancelScan() {
_cancelScan = true;
state = state.copyWith(
progress: const MediaLibraryScanProgress(phase: '正在停止扫描'),
);
}
void setSearchQuery(String query) {
state = state.copyWith(searchQuery: query);
}
void clearError() {
state = state.copyWith(clearError: true);
}
Future<List<CloudFile>> _scanSource(
String? rootID,
String rootPath, {
required bool recursive,
required int minimumSizeBytes,
}) async {
final folders = <_ScanFolder>[_ScanFolder(rootID, rootPath)];
final visited = <String>{};
final mediaFiles = <CloudFile>[];
while (folders.isNotEmpty && !_cancelScan) {
final folder = folders.removeAt(0);
final visitKey = folder.id ?? 'root';
if (!visited.add(visitKey)) continue;
var page = 0;
while (!_cancelScan) {
final response = await _api!.fsFiles(
parentID: folder.id,
page: page,
pageSize: 200,
orderBy: 0,
sortType: 0,
);
final files = _extractFiles(response);
for (final file in files) {
if (file.isDirectory) {
if (recursive) {
folders.add(_ScanFolder(file.id, '${folder.path}/${file.name}'));
}
} else if (file.isVideo && (file.size ?? 0) >= minimumSizeBytes) {
mediaFiles.add(_withPath(file, '${folder.path}/${file.name}'));
}
}
state = state.copyWith(
progress: MediaLibraryScanProgress(
phase: '扫描 ${folder.path}',
completed: mediaFiles.length,
total: folders.length + visited.length,
),
);
if (files.length < 200) break;
page += 1;
}
}
return mediaFiles;
}
CloudFile _withPath(CloudFile file, String path) {
return CloudFile(
id: file.id,
name: file.name,
isDirectory: file.isDirectory,
size: file.size,
gcid: file.gcid,
subDirectoryCount: file.subDirectoryCount,
subFileCount: file.subFileCount,
modifiedAt: file.modifiedAt,
cloudPath: path,
fileType: file.fileType,
);
}
List<CloudFile> _extractFiles(Map<String, dynamic> json) {
final result = <CloudFile>[];
final seen = <String>{};
void appendList(List<dynamic> values) {
for (final value in values) {
if (value is Map) {
try {
final file = CloudFile.fromJson(Map<String, dynamic>.from(value));
if (seen.add(file.id)) result.add(file);
} catch (_) {}
}
}
}
final preferred = _findArrayDeep(json, const [
'list',
'files',
'fileList',
'items',
'records',
'rows',
'resList',
'resourceList',
]);
if (preferred != null) {
appendList(preferred);
return result;
}
void visit(dynamic value) {
if (value is Map) {
try {
final file = CloudFile.fromJson(Map<String, dynamic>.from(value));
if (seen.add(file.id)) result.add(file);
} catch (_) {}
for (final child in value.values) {
visit(child);
}
} else if (value is List) {
for (final child in value) {
visit(child);
}
}
}
visit(json);
return result;
}
List<MediaLibraryDefinition> _loadLibraries() {
final raw = StorageManager.get<dynamic>(StorageKeys.mediaLibraries);
if (raw is! List) return [];
return raw
.whereType<Map>()
.map(
(item) =>
MediaLibraryDefinition.fromJson(Map<String, dynamic>.from(item)),
)
.toList();
}
List<MediaLibraryItem> _loadItems(String libraryID) {
return _loadAllItems()
.where((item) => item.libraryID == libraryID)
.toList();
}
List<MediaLibraryItem> _loadAllItems() {
final raw = StorageManager.get<dynamic>(StorageKeys.mediaLibraryItems);
if (raw is! List) return [];
return raw
.whereType<Map>()
.map(
(item) => MediaLibraryItem.fromJson(Map<String, dynamic>.from(item)),
)
.where((item) => item.file.id.isNotEmpty)
.toList();
}
Future<void> _saveLibraries(List<MediaLibraryDefinition> libraries) {
return StorageManager.set(
StorageKeys.mediaLibraries,
libraries.map((library) => library.toJson()).toList(),
);
}
Future<void> _saveAllItems(List<MediaLibraryItem> items) {
return StorageManager.set(
StorageKeys.mediaLibraryItems,
items.map((item) => item.toJson()).toList(),
);
}
static List<dynamic>? _findArrayDeep(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final value = json[key];
if (value is List) return value;
}
const preferredKeys = ['data', 'result', 'payload'];
for (final key in preferredKeys) {
final value = json[key];
if (value is Map<String, dynamic>) {
final found = _findArrayDeep(value, keys);
if (found != null) return found;
}
}
for (final entry in json.entries) {
if (preferredKeys.contains(entry.key)) continue;
final value = entry.value;
if (value is Map<String, dynamic>) {
final found = _findArrayDeep(value, keys);
if (found != null) return found;
}
}
return null;
}
}
class _ScanFolder {
final String? id;
final String path;
const _ScanFolder(this.id, this.path);
}
final mediaLibraryProvider =
StateNotifierProvider<MediaLibraryNotifier, MediaLibraryState>(
(ref) => MediaLibraryNotifier(),
);
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/legacy.dart';
import '../core/storage/storage_manager.dart';
class ThemeState {
final ThemeMode themeMode;
const ThemeState({this.themeMode = ThemeMode.system});
ThemeState copyWith({ThemeMode? themeMode}) =>
ThemeState(themeMode: themeMode ?? this.themeMode);
}
class ThemeNotifier extends StateNotifier<ThemeState> {
ThemeNotifier() : super(const ThemeState()) {
_load();
}
void _load() {
final saved = StorageManager.get<String>(StorageKeys.themeMode);
if (saved == 'light') {
state = const ThemeState(themeMode: ThemeMode.light);
} else if (saved == 'dark') {
state = const ThemeState(themeMode: ThemeMode.dark);
} else {
state = const ThemeState();
}
}
Future<void> setThemeMode(ThemeMode mode) async {
state = state.copyWith(themeMode: mode);
final name =
mode == ThemeMode.light ? 'light' : mode == ThemeMode.dark ? 'dark' : 'system';
await StorageManager.set(StorageKeys.themeMode, name);
}
}
final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeState>(
(ref) => ThemeNotifier(),
);
+19
View File
@@ -0,0 +1,19 @@
extension StringExtensions on String {
String truncate(int maxLen) {
if (length <= maxLen) return this;
return '${substring(0, maxLen)}';
}
}
extension IntExtensions on int {
String get formattedBytes {
if (this < 1024) return '$this B';
if (this < 1024 * 1024) {
return '${(this / 1024).toStringAsFixed(1)} KB';
}
if (this < 1024 * 1024 * 1024) {
return '${(this / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(this / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../models/cloud_file.dart';
class BreadcrumbBar extends StatelessWidget {
final List<CloudFile> path;
final ValueChanged<int> onNavigate;
const BreadcrumbBar({
super.key,
required this.path,
required this.onNavigate,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final items = <Widget>[
// Home button
GestureDetector(
onTap: () => onNavigate(-1),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: path.isEmpty ? cs.primary.withAlpha(15) : null,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'云盘',
style: TextStyle(
fontSize: 13,
fontWeight: path.isEmpty ? FontWeight.w600 : FontWeight.normal,
color: path.isEmpty ? cs.primary : cs.mutedForeground,
),
),
),
),
),
];
for (int i = 0; i < path.length; i++) {
final isLast = i == path.length - 1;
items.add(
Icon(
Icons.chevron_right_rounded,
size: 16,
color: cs.mutedForeground.withAlpha(100),
),
);
items.add(
GestureDetector(
onTap: isLast ? null : () => onNavigate(i),
child: MouseRegion(
cursor: isLast ? SystemMouseCursors.basic : SystemMouseCursors.click,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isLast ? cs.primary.withAlpha(15) : null,
borderRadius: BorderRadius.circular(6),
),
child: Text(
path[i].name,
style: TextStyle(
fontSize: 13,
fontWeight: isLast ? FontWeight.w600 : FontWeight.normal,
color: isLast ? cs.primary : cs.mutedForeground,
),
),
),
),
),
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: items),
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
Future<bool> showConfirmDialog(
BuildContext context, {
required String title,
required String content,
String confirmText = '确认',
String cancelText = '取消',
}) async {
final result = await showShadDialog<bool>(
context: context,
builder: (context) => ShadDialog(
title: Text(title),
description: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(content),
),
actions: [
ShadButton.outline(
child: Text(cancelText),
onPressed: () => Navigator.of(context).pop(false),
),
ShadButton(
child: Text(confirmText),
onPressed: () => Navigator.of(context).pop(true),
),
],
),
);
return result ?? false;
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/cloud_file.dart';
class FileIcon extends StatelessWidget {
final CloudFile file;
final double size;
const FileIcon({
super.key,
required this.file,
this.size = 28,
});
@override
Widget build(BuildContext context) {
if (file.isDirectory) {
return Icon(
LucideIcons.folder,
size: size,
color: const Color(0xFFF59E0B),
);
}
final iconData = _getIconForFileType(file.fileType);
final color = _getColorForFileType(file.fileType);
return Icon(
iconData,
size: size,
color: color,
);
}
IconData _getIconForFileType(int fileType) {
switch (fileType) {
case 1:
return LucideIcons.image;
case 2:
return LucideIcons.film;
case 3:
return LucideIcons.music;
case 4:
return LucideIcons.fileText;
case 5:
case 9:
return LucideIcons.archive;
default:
return LucideIcons.file;
}
}
Color _getColorForFileType(int fileType) {
switch (fileType) {
case 1:
return const Color(0xFF10B981);
case 2:
return const Color(0xFF3B82F6);
case 3:
return const Color(0xFFF59E0B);
case 4:
return const Color(0xFF8B5CF6);
case 5:
case 9:
return const Color(0xFF6B7280);
default:
return const Color(0xFF6B7280);
}
}
}
+192
View File
@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../models/cloud_file.dart';
import 'file_icon.dart';
/// A single file row in the file list with context menu support.
class FileListTile extends StatelessWidget {
final CloudFile file;
final bool isSelected;
final VoidCallback? onSelect;
final VoidCallback? onOpen;
final VoidCallback? onRename;
final VoidCallback? onCopy;
final VoidCallback? onCut;
final VoidCallback? onDownload;
final VoidCallback? onShare;
final VoidCallback? onDelete;
const FileListTile({
super.key,
required this.file,
this.isSelected = false,
this.onSelect,
this.onOpen,
this.onRename,
this.onCopy,
this.onCut,
this.onDownload,
this.onShare,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final isDark = theme.brightness == Brightness.dark;
return ShadContextMenuRegion(
items: [
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.folderOpen, size: 16),
trailing: const Icon(LucideIcons.chevronRight),
onPressed: onOpen,
child: const Text('打开'),
),
const ShadContextMenuItem.inset(
enabled: false,
child: Text('重命名'),
),
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.copy, size: 16),
trailing: const Icon(LucideIcons.chevronRight),
onPressed: onCopy,
child: const Text('复制'),
),
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.scissors, size: 16),
trailing: const Icon(LucideIcons.chevronRight),
onPressed: onCut,
child: const Text('剪切'),
),
const Divider(height: 8),
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.download, size: 16),
trailing: const Icon(LucideIcons.chevronRight),
onPressed: onDownload,
child: const Text('下载'),
),
ShadContextMenuItem.inset(
leading: const Icon(LucideIcons.share2, size: 16),
trailing: const Icon(LucideIcons.chevronRight),
onPressed: onShare,
child: const Text('分享'),
),
const Divider(height: 8),
ShadContextMenuItem.inset(
leading: Icon(LucideIcons.trash2, size: 16, color: theme.colorScheme.destructive),
trailing: Icon(LucideIcons.chevronRight, color: theme.colorScheme.destructive),
onPressed: onDelete,
child: Text(
'删除',
style: TextStyle(color: theme.colorScheme.destructive),
),
),
],
child: GestureDetector(
onTap: () => onSelect?.call(),
onDoubleTap: onOpen,
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: isSelected
? (isDark
? theme.colorScheme.primary.withAlpha(30)
: theme.colorScheme.primary.withAlpha(15))
: (isDark
? Colors.white.withAlpha(3)
: Colors.black.withAlpha(2)),
border: Border(
bottom: BorderSide(
color: isDark
? Colors.white.withAlpha(10)
: Colors.black.withAlpha(8),
width: 0.5,
),
),
),
child: Row(
children: [
// Selection indicator
if (isSelected)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
LucideIcons.checkCircle,
size: 18,
color: theme.colorScheme.primary,
),
),
// File icon
SizedBox(
width: 32,
height: 32,
child: FileIcon(file: file),
),
const SizedBox(width: 12),
// File name
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.foreground,
),
overflow: TextOverflow.ellipsis,
),
if (file.isDirectory && file.subFileCount != null)
Text(
'${file.subFileCount} 个项目',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.mutedForeground,
),
),
],
),
),
// File size
if (!file.isDirectory)
SizedBox(
width: 80,
child: Text(
file.formattedSize,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.mutedForeground,
),
),
),
const SizedBox(width: 12),
// Modified date
SizedBox(
width: 120,
child: Text(
file.modifiedAt,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.mutedForeground,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
}
+302
View File
@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../providers/file_provider.dart';
import '../providers/auth_provider.dart';
import '../models/cloud_file.dart';
class SidePanel extends ConsumerWidget {
const SidePanel({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = ShadTheme.of(context);
return Container(
width: 280,
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(left: BorderSide(color: theme.colorScheme.border)),
),
child: Column(
children: [
Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Icon(LucideIcons.info, size: 18, color: theme.colorScheme.foreground),
const SizedBox(width: 8),
Text(
'详情',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
),
],
),
),
const ShadSeparator.horizontal(),
Expanded(child: _buildContent(context, ref)),
],
),
);
}
Widget _buildContent(BuildContext context, WidgetRef ref) {
final fileState = ref.watch(fileProvider);
final selected = fileState.files
.where((f) => fileState.selectedIDs.contains(f.id))
.toList();
if (selected.isEmpty) {
return _buildDefaultContent(context, ref);
}
if (selected.length == 1) {
return _buildSingleFileDetail(context, selected.first, ref);
}
return _buildMultiSelectDetail(context, selected, ref);
}
Widget _buildDefaultContent(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final fileState = ref.watch(fileProvider);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(context, '账号信息'),
const SizedBox(height: 12),
_infoRow(context, '用户名', auth.userName),
_infoRow(context, '会员等级', auth.memberLevel),
_infoRow(context, '存储空间', auth.capacityText),
const SizedBox(height: 24),
if (fileState.section == WorkspaceSection.recycle) ...[
_sectionTitle(context, '回收站'),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ShadButton.outline(
onPressed: () {
showShadDialog(
context: context,
builder: (context) => ShadDialog(
title: const Text('清空回收站'),
description: const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text('确定要永久删除回收站中的所有文件吗?此操作不可恢复。'),
),
actions: [
ShadButton.outline(
child: const Text('取消'),
onPressed: () => Navigator.of(context).pop(),
),
ShadButton(
child: const Text('清空'),
onPressed: () {
ref.read(fileProvider.notifier).clearRecycleBin();
Navigator.of(context).pop();
},
),
],
),
);
},
leading: const Icon(LucideIcons.trash2, size: 16),
child: const Text('清空回收站'),
),
),
],
],
),
);
}
Widget _buildSingleFileDetail(
BuildContext context, CloudFile file, WidgetRef ref) {
final theme = ShadTheme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Column(
children: [
Icon(
file.isDirectory ? LucideIcons.folder : LucideIcons.file,
size: 64,
color: file.isDirectory
? const Color(0xFFF59E0B)
: theme.colorScheme.primary,
),
const SizedBox(height: 12),
Text(
file.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 24),
_sectionTitle(context, '文件信息'),
const SizedBox(height: 12),
_infoRow(context, '类型', file.typeName),
if (!file.isDirectory) _infoRow(context, '大小', file.formattedSize),
if (file.modifiedAt.isNotEmpty)
_infoRow(context, '修改时间', file.modifiedAt),
const SizedBox(height: 24),
_sectionTitle(context, '操作'),
const SizedBox(height: 12),
_buildActionButtons(context, file, ref),
],
),
);
}
Widget _buildMultiSelectDetail(
BuildContext context, List<CloudFile> files, WidgetRef ref) {
final theme = ShadTheme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Column(
children: [
Icon(
LucideIcons.checkCircle,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 12),
Text(
'已选择 ${files.length} 个项目',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
),
],
),
),
const SizedBox(height: 24),
_sectionTitle(context, '批量操作'),
const SizedBox(height: 12),
_buildActionButtons(context, files.first, ref),
],
),
);
}
Widget _buildActionButtons(
BuildContext context, CloudFile file, WidgetRef ref) {
final theme = ShadTheme.of(context);
final fp = ref.read(fileProvider.notifier);
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (file.isDirectory)
_actionChip(
context,
icon: LucideIcons.folderOpen,
label: '打开',
onTap: () => fp.navigateToFolder(file),
),
_actionChip(
context,
icon: LucideIcons.copy,
label: '复制',
onTap: () => fp.copyToClipboard([file]),
),
_actionChip(
context,
icon: LucideIcons.scissors,
label: '剪切',
onTap: () => fp.cutToClipboard([file]),
),
_actionChip(
context,
icon: LucideIcons.download,
label: '下载',
onTap: () => fp.downloadFile(file),
),
_actionChip(
context,
icon: LucideIcons.share2,
label: '分享',
onTap: () {},
),
_actionChip(
context,
icon: LucideIcons.trash2,
label: '删除',
color: theme.colorScheme.destructive,
onTap: () => fp.deleteFiles([file]),
),
],
);
}
Widget _actionChip(
BuildContext context, {
required IconData icon,
required String label,
Color? color,
required VoidCallback onTap,
}) {
return ShadButton.outline(
onPressed: onTap,
leading: Icon(icon, size: 14, color: color),
child: Text(label, style: TextStyle(fontSize: 12, color: color)),
);
}
Widget _sectionTitle(BuildContext context, String title) {
final theme = ShadTheme.of(context);
return Text(
title,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
);
}
Widget _infoRow(BuildContext context, String label, String value) {
final theme = ShadTheme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(fontSize: 13, color: theme.colorScheme.mutedForeground),
),
Flexible(
child: Text(
value,
style: TextStyle(fontSize: 13, color: theme.colorScheme.foreground),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../providers/file_provider.dart';
class SortMenu extends StatelessWidget {
final FileSort currentSort;
final SortDirection currentDirection;
final ValueChanged<FileSort> onSortChanged;
const SortMenu({
super.key,
required this.currentSort,
required this.currentDirection,
required this.onSortChanged,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ShadButton.ghost(
onPressed: () => _showSortMenu(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.sort_rounded, size: 16, color: cs.mutedForeground),
const SizedBox(width: 4),
Text('排序', style: TextStyle(fontSize: 13, color: cs.mutedForeground)),
],
),
);
}
void _showSortMenu(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
showModalBottomSheet(
context: context,
builder: (ctx) => Container(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Text('排序方式',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: cs.foreground)),
const Spacer(),
Text('当前: ${currentSort.title}',
style: TextStyle(fontSize: 12, color: cs.mutedForeground)),
],
),
),
const ShadSeparator.horizontal(),
for (final sort in FileSort.values)
ListTile(
dense: true,
leading: currentSort == sort
? Icon(Icons.check_rounded, size: 16, color: cs.primary)
: const SizedBox(width: 16),
title: Text(
sort.title,
style: TextStyle(
fontSize: 14,
fontWeight: currentSort == sort ? FontWeight.w600 : FontWeight.normal,
color: currentSort == sort ? cs.primary : cs.foreground,
),
),
trailing: currentSort == sort
? Icon(
currentDirection == SortDirection.ascending
? Icons.arrow_upward_rounded
: Icons.arrow_downward_rounded,
size: 14,
color: cs.primary,
)
: null,
onTap: () {
Navigator.of(ctx).pop();
onSortChanged(sort);
},
),
],
),
),
);
}
}
+7
View File
@@ -0,0 +1,7 @@
# Flutter-related
**/Flutter/ephemeral/
**/Pods/
# Xcode-related
**/dgph
**/xcuserdata/
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
@@ -0,0 +1,32 @@
//
// Generated file. Do not edit.
//
import FlutterMacOS
import Foundation
import desktop_drop
import device_info_plus
import file_picker
import file_selector_macos
import package_info_plus
import pasteboard
import screen_retriever_macos
import share_plus
import sqflite_darwin
import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}
+42
View File
@@ -0,0 +1,42 @@
platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end
+78
View File
@@ -0,0 +1,78 @@
PODS:
- device_info_plus (0.0.1):
- FlutterMacOS
- file_picker (0.0.1):
- Flutter
- FlutterMacOS
- file_selector_macos (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- package_info_plus (0.0.1):
- FlutterMacOS
- pasteboard (0.0.1):
- FlutterMacOS
- screen_retriever_macos (0.0.1):
- FlutterMacOS
- share_plus (0.0.1):
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- window_manager (0.5.0):
- FlutterMacOS
DEPENDENCIES:
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/darwin`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
EXTERNAL SOURCES:
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/darwin
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
FlutterMacOS:
:path: Flutter/ephemeral
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
pasteboard:
:path: Flutter/ephemeral/.symlinks/plugins/pasteboard/macos
screen_retriever_macos:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
sqflite_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
window_manager:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_picker: 70164d9778c42c47218d6cd79ce435de0856b11a
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
pasteboard: b594eaf838d930b276d7a35a44a32b4f489170cb
screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
window_manager: b729e31d38fb04905235df9ea896128991cad99e
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
COCOAPODS: 1.17.0
+801
View File
@@ -0,0 +1,801 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXAggregateTarget section */
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
isa = PBXAggregateTarget;
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
buildPhases = (
33CC111E2044C6BF0003C045 /* ShellScript */,
);
dependencies = (
);
name = "Flutter Assemble";
productName = FLX;
};
/* End PBXAggregateTarget section */
/* Begin PBXBuildFile section */
30ED687A1F2E50177945E875 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6446EF046C2C6AC038CB40BD /* Pods_RunnerTests.framework */; };
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
FBB1FE407DD0FBF7D310AF91 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 39D84C9718FE617BAC88B7EC /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC10EC2044A3C60003C045;
remoteInfo = Runner;
};
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
remoteInfo = FLX;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
33CC110E2044A8840003C045 /* Bundle Framework */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Bundle Framework";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
203754356FBF6C3A1A7772A0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* guangya_flutter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = guangya_flutter.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
39D84C9718FE617BAC88B7EC /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
6446EF046C2C6AC038CB40BD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
67882BE04763EB69D4B27F31 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
6C62FE0409E6C83E716A8B82 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
76EF5F46423B051CFB22CF3B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
9BF015CF325D3E6229F36067 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
D0F30034D64889B5CF8D9566 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
331C80D2294CF70F00263BE5 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
30ED687A1F2E50177945E875 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EA2044A3C60003C045 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FBB1FE407DD0FBF7D310AF91 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C80D6294CF71000263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C80D7294CF71000263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
33BA886A226E78AF003329D5 /* Configs */ = {
isa = PBXGroup;
children = (
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
);
path = Configs;
sourceTree = "<group>";
};
33CC10E42044A3C60003C045 = {
isa = PBXGroup;
children = (
33FAB671232836740065AC1E /* Runner */,
33CEB47122A05771004F2AC0 /* Flutter */,
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
D9A94B78B855426E12531ED1 /* Pods */,
);
sourceTree = "<group>";
};
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* guangya_flutter.app */,
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
33CC11242044D66E0003C045 /* Resources */ = {
isa = PBXGroup;
children = (
33CC10F22044A3C60003C045 /* Assets.xcassets */,
33CC10F42044A3C60003C045 /* MainMenu.xib */,
33CC10F72044A3C60003C045 /* Info.plist */,
);
name = Resources;
path = ..;
sourceTree = "<group>";
};
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
);
path = Flutter;
sourceTree = "<group>";
};
33FAB671232836740065AC1E /* Runner */ = {
isa = PBXGroup;
children = (
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
33BA886A226E78AF003329D5 /* Configs */,
);
path = Runner;
sourceTree = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
39D84C9718FE617BAC88B7EC /* Pods_Runner.framework */,
6446EF046C2C6AC038CB40BD /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
D9A94B78B855426E12531ED1 /* Pods */ = {
isa = PBXGroup;
children = (
6C62FE0409E6C83E716A8B82 /* Pods-Runner.debug.xcconfig */,
9BF015CF325D3E6229F36067 /* Pods-Runner.release.xcconfig */,
203754356FBF6C3A1A7772A0 /* Pods-Runner.profile.xcconfig */,
67882BE04763EB69D4B27F31 /* Pods-RunnerTests.debug.xcconfig */,
76EF5F46423B051CFB22CF3B /* Pods-RunnerTests.release.xcconfig */,
D0F30034D64889B5CF8D9566 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C80D4294CF70F00263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
3209712560DED3DB3073D66D /* [CP] Check Pods Manifest.lock */,
331C80D1294CF70F00263BE5 /* Sources */,
331C80D2294CF70F00263BE5 /* Frameworks */,
331C80D3294CF70F00263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C80DA294CF71000263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
33CC10EC2044A3C60003C045 /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
34496576AF4B2111AE93F7E8 /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
AD4CA8CB7185A0DA6C94B889 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
33CC11202044C79F0003C045 /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* guangya_flutter.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C80D4294CF70F00263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 33CC10EC2044A3C60003C045;
};
33CC10EC2044A3C60003C045 = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.Sandbox = {
enabled = 1;
};
};
};
33CC111A2044C6BA0003C045 = {
CreatedOnToolsVersion = 9.2;
ProvisioningStyle = Manual;
};
};
};
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 33CC10E42044A3C60003C045;
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
33CC10EC2044A3C60003C045 /* Runner */,
331C80D4294CF70F00263BE5 /* RunnerTests */,
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C80D3294CF70F00263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10EB2044A3C60003C045 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3209712560DED3DB3073D66D /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
};
33CC111E2044C6BF0003C045 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
Flutter/ephemeral/FlutterInputs.xcfilelist,
);
inputPaths = (
Flutter/ephemeral/tripwire,
);
outputFileListPaths = (
Flutter/ephemeral/FlutterOutputs.xcfilelist,
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
34496576AF4B2111AE93F7E8 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
AD4CA8CB7185A0DA6C94B889 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C80D1294CF70F00263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
33CC10E92044A3C60003C045 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC10EC2044A3C60003C045 /* Runner */;
targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
};
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
isa = PBXVariantGroup;
children = (
33CC10F52044A3C60003C045 /* Base */,
);
name = MainMenu.xib;
path = Runner;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
331C80DB294CF71000263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 67882BE04763EB69D4B27F31 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ptools.guangyaFlutter.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/guangya_flutter.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/guangya_flutter";
};
name = Debug;
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 76EF5F46423B051CFB22CF3B /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ptools.guangyaFlutter.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/guangya_flutter.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/guangya_flutter";
};
name = Release;
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = D0F30034D64889B5CF8D9566 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ptools.guangyaFlutter.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/guangya_flutter.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/guangya_flutter";
};
name = Profile;
};
338D0CE9231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
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_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
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_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Profile;
};
338D0CEA231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Profile;
};
338D0CEB231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Profile;
};
33CC10F92044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
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_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
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_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
33CC10FA2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
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_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
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_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
33CC10FC2044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
33CC10FD2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Release;
};
33CC111C2044C6BA0003C045 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
};
33CC111D2044C6BA0003C045 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C80DB294CF71000263BE5 /* Debug */,
331C80DC294CF71000263BE5 /* Release */,
331C80DD294CF71000263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10F92044A3C60003C045 /* Debug */,
33CC10FA2044A3C60003C045 /* Release */,
338D0CE9231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10FC2044A3C60003C045 /* Debug */,
33CC10FD2044A3C60003C045 /* Release */,
338D0CEA231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC111C2044C6BA0003C045 /* Debug */,
33CC111D2044C6BA0003C045 /* Release */,
338D0CEB231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
}
@@ -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>

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