commit 6bcc3fdf18718e3d7adaab297dd1f7772beb638e Author: ngfchl Date: Sat Jul 18 10:23:34 2026 +0800 chore: initialize Guangya Flutter project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..6416eb3 --- /dev/null +++ b/.metadata @@ -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' diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d609e6 --- /dev/null +++ b/README.md @@ -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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..39079e8 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..eb1f296 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/ptools/guangya_flutter/MainActivity.kt b/android/app/src/main/kotlin/com/ptools/guangya_flutter/MainActivity.kt new file mode 100644 index 0000000..aa92f26 --- /dev/null +++ b/android/app/src/main/kotlin/com/ptools/guangya_flutter/MainActivity.kt @@ -0,0 +1,5 @@ +package com.ptools.guangya_flutter + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -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 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..38a6ef0 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + 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 = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,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 */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -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) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..4e2dd69 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,97 @@ + + + + + ITSAppUsesNonExemptEncryption + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 光鸭云盘 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + guangya_flutter + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIStatusBarHidden + + UIViewControllerBasedStatusBarAppearance + + NSFileProtection + NSFileProtectionCompleteUntilFirstUserAuthentication + UIFileSharingEnabled + + NSPhotoLibraryUsageDescription + 我们需要访问您的照片库以便上传照片。 + NSPhotoLibraryAddUsageDescription + 用于保存截图到相册 + NSCameraUsageDescription + 我们需要使用相机来拍摄照片。 + NSLocalNetworkUsageDescription + 光鸭云盘需要访问本地网络来发现和连接设备。 + NSBonjourServices + + _googlecast._tcp + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/api/guangya_api.dart b/lib/api/guangya_api.dart new file mode 100644 index 0000000..e8b49be --- /dev/null +++ b/lib/api/guangya_api.dart @@ -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 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> loginSMSInit(String phoneNumber, + {String? captchaToken}) async { + final body = { + '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> 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> 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> 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> loginQRInit() async { + return Http.accountRequest('/v1/auth/device/code', body: { + 'scope': 'user', + 'client_id': AppConfig.clientID, + }); + } + + Future> 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> 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> userInfo() async { + return Http.accountRequest('/v1/user/me', body: null, authenticated: true); + } + + // ── Cloud downloads ──────────────────────────────────────────────── + + Future> cloudTaskList({ + int page = 0, + int pageSize = 50, + List status = const [0, 1, 3, 4], + }) async { + return Http.apiRequest('/nd.bizcloudcollection.s/v1/list_task', body: { + 'page': page, + 'pageSize': pageSize, + 'status': status, + }); + } + + Future> cloudResolveURL(String url) async { + return Http.apiRequest('/nd.bizcloudcollection.s/v1/resolve_res', + body: {'url': url}); + } + + Future> 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> cloudCreateTask(String url, + {String? parentID}) async { + return Http.apiRequest('/nd.bizcloudcollection.s/v1/create_task', body: { + 'url': url, + 'parentId': parentID ?? '', + }); + } + + Future> taskStatus(String taskID) async { + return Http.apiRequest('/nd.bizuserres.s/v1/get_task_status', + body: {'taskId': taskID}); + } + + // ── File system ──────────────────────────────────────────────────── + + Future> fsFiles({ + String? parentID, + int page = 0, + int pageSize = 50, + int orderBy = 0, + int sortType = 0, + List? fileTypes, + int? resType, + int? dirType, + bool needPlayRecord = false, + }) async { + final body = { + '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> fsCreateDir(String name, + {String? parentID, bool failIfNameExist = false}) async { + final body = { + '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> fsCopy(List fileIDs, + {String? parentID}) async { + return Http.apiRequest('/nd.bizuserres.s/v1/file/copy_file', body: { + 'fileIds': fileIDs, + 'parentId': parentID ?? '', + }); + } + + Future> fsMove(List fileIDs, + {String? parentID}) async { + return Http.apiRequest('/nd.bizuserres.s/v1/file/move_file', body: { + 'fileIds': fileIDs, + 'parentId': parentID ?? '', + }); + } + + Future> fsDelete(List fileIDs) async { + return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_file', + body: {'fileIds': fileIDs}); + } + + Future> fsRecycle(List fileIDs) async { + return Http.apiRequest('/nd.bizuserres.s/v1/file/recycle_file', + body: {'fileIds': fileIDs}); + } + + Future> fsClearRecycleBin() async { + return Http.apiRequest('/nd.bizuserres.s/v1/file/clear_recycle_bin'); + } + + Future> fsRename(String fileID, String newName) async { + return Http.apiRequest('/nd.bizuserres.s/v1/file/rename', body: { + 'fileId': fileID, + 'newName': newName, + }); + } + + Future> fsDetail(String fileID) async { + return Http.apiRequest('/nd.bizuserres.s/v1/file/get_file_detail', + body: {'fileId': fileID}); + } + + Future> fsImageList() async { + return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [1], resType: 1); + } + + Future> fsVideoList() async { + return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [2], resType: 1); + } + + Future> fsAudioList() async { + return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [3], resType: 1, needPlayRecord: true); + } + + Future> fsDocumentList() async { + return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [4], resType: 1); + } + + Future> fsRecycleFiles() async { + return fsFiles(orderBy: 10, dirType: 4); + } + + Future> downloadURL(String fileID) async { + return Http.apiRequest('/nd.bizuserres.s/v1/get_res_download_url', + body: {'fileId': fileID}); + } + + Future> vodDownloadURL(String fileID, String gcid) async { + return Http.apiRequest('/userres/v1/file/get_vod_download_url', + body: {'fileId': fileID, 'gcid': gcid}); + } + + Future> recentViewed({ + int pageSize = 100, + String cursor = '', + }) async { + return Http.apiRequest('/userres/v1/get_user_action', + body: {'cursor': cursor, 'pageSize': pageSize}); + } + + Future> recentRestored({int pageSize = 100}) async { + return Http.apiRequest('/userres/v1/get_restore_list', body: { + 'pageSize': pageSize, + 'orderBy': 2, + 'sortType': 1, + }); + } + + // ── Shares ───────────────────────────────────────────────────────── + + Future> shareCreate( + List 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> 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> shareDelete(List ids) async { + return Http.apiRequest('/nd.bizuserres.s/v1/delete_share', + body: {'ids': ids}); + } + + Future> 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> shareRestore( + String accessToken, + List fileIDs, { + String parentID = '', + }) async { + return Http.apiRequest('/nd.bizuserres.s/v1/restore_share', body: { + 'accessToken': accessToken, + 'fileIds': fileIDs, + 'parentId': parentID, + }); + } + + Future> shareDownloadURL( + String fileID, String accessToken) async { + return Http.apiRequest('/nd.bizuserres.s/v1/get_share_download_url', + body: {'fileId': fileID, 'accessToken': accessToken}); + } + + Future> shareFilesSize( + String accessToken, + List fileIDs, { + bool download = true, + }) async { + return Http.apiRequest('/nd.bizuserres.s/v1/get_share_files_size', + body: { + 'accessToken': accessToken, + 'fileIds': fileIDs, + 'download': download, + }); + } + + Future> 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> shareSummary(String shareID) async { + return Http.publicRequest('/nd.bizuserres.s/v1/get_share_summary', + body: {'shareId': shareID}); + } + + Future> shareAccessToken( + String shareID, String code) async { + return Http.publicRequest('/nd.bizuserres.s/v1/get_share_access_token', + body: {'shareId': shareID, 'code': code}); + } + + // ── Upload ───────────────────────────────────────────────────────── + + Future> uploadToken({ + required String name, + required int fileSize, + String? parentID, + String? md5, + }) async { + final res = {'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> 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> 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> deleteUploadTask(List taskIDs) async { + return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_upload_task', + body: {'taskIds': taskIDs}); + } + + Future> checkCanFlashUpload( + String taskID, String gcid) async { + return Http.apiRequest('/nd.bizuserres.s/v1/check_can_flash_upload', + body: {'taskId': taskID, 'gcid': gcid}); + } + + Future> 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> 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 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 _cdnUpload( + List bytes, { + required Map tokenData, + required String contentType, + required int chunkSize, + }) async { + final creds = _findMapDeep(tokenData, ['creds']) ?? const {}; + 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 = >[]; + 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 = + '${parts.map((part) => '${part.key}"${part.value}"').join()}'; + 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 headers})> _ossRequest({ + required String method, + required String url, + required String bucket, + required String objectKey, + required String accessKeyID, + required String secret, + required String sessionToken, + List content = const [], + String contentType = '', + String contentMD5 = '', + Map 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>( + 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> 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> 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> 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> _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; + } + + // ── Helpers ─────────────────────────────────────────────────────── + + static String? _findStringDeep(Map? json, List 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) { + final found = + _findStringDeep(entry.value as Map, keys); + if (found != null) return found; + } + } + return null; + } + + static int? _findIntDeep(Map? json, List 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? json, List 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.from(entry.value as Map), + keys, + ); + if (found != null) return found; + } + } + return null; + } + + static Map? _findMapDeep( + Map? json, + List keys, + ) { + if (json == null) return null; + for (final key in keys) { + final value = json[key]; + if (value is Map) return Map.from(value); + } + for (final entry in json.entries) { + if (entry.value is Map) { + final found = _findMapDeep( + Map.from(entry.value as Map), + keys, + ); + if (found != null) return found; + } + } + return null; + } + + static String _subResourceQuery(Map 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>(.*?)').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 = []; + 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 bytes) { + final length = bytes.length; + final chunkSize = length <= 0x8000000 + ? 262144 + : length <= 0x10000000 + ? 524288 + : length <= 0x20000000 + ? 1048576 + : 2097152; + final hashes = []; + 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 实例是静态的,不需要手动关闭 + } +} diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..c23b470 --- /dev/null +++ b/lib/app/app.dart @@ -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(), + ); + } +} diff --git a/lib/app/app_theme.dart b/lib/app/app_theme.dart new file mode 100644 index 0000000..b7fe914 --- /dev/null +++ b/lib/app/app_theme.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart new file mode 100644 index 0000000..0d0c13b --- /dev/null +++ b/lib/core/config/app_config.dart @@ -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(StorageKeys.apiBase) ?? _defaultApiBase; + } + + /// 设置 API baseUrl + static Future setApiBase(String url) async { + await StorageManager.set(StorageKeys.apiBase, normalizeUrl(url)); + } + + /// 获取账号 baseUrl + static String get accountBase { + return StorageManager.get(StorageKeys.accountBase) ?? _defaultAccountBase; + } + + /// 设置账号 baseUrl + static Future 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 clear() async { + await StorageManager.delete(StorageKeys.apiBase); + await StorageManager.delete(StorageKeys.accountBase); + } +} diff --git a/lib/core/http/dio_client.dart b/lib/core/http/dio_client.dart new file mode 100644 index 0000000..68fc846 --- /dev/null +++ b/lib/core/http/dio_client.dart @@ -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 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; + } +} diff --git a/lib/core/http/http.dart b/lib/core/http/http.dart new file mode 100644 index 0000000..4032832 --- /dev/null +++ b/lib/core/http/http.dart @@ -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 request( + String path, { + String method = 'POST', + Map? queryParameters, + dynamic data, + Map? 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 请求(带 token,code==200 为成功) + static Future> apiRequest( + String path, { + String method = 'POST', + dynamic body, + Map? headers, + CancelToken? cancelToken, + List allowedCodes = const [], + }) async { + // 过期自动刷新 + _checkTokenExpiry(); + + final mergedHeaders = { + 'traceparent': _generateTraceparent(), + ...?headers, + }; + + final data = await request>( + 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> accountRequest( + String path, { + String method = 'POST', + Map? body, + Map? extraHeaders, + bool authenticated = false, + }) async { + final headers = { + 'did': _getDeviceID(), + 'dt': '4', + ...?extraHeaders, + }; + + if (authenticated) { + final token = StorageManager.get(StorageKeys.accessToken); + if (token != null && token.isNotEmpty) { + headers['authorization'] = 'Bearer $token'; + } + } + + final data = await request>( + path, + method: method, + data: body, + headers: headers, + useAccountDio: true, + options: Options(extra: {ResponseInterceptor.skipCodeCheckKey: true}), + ); + + return data; + } + + /// 公开请求(无 token,用于分享预览等) + static Future> publicRequest( + String path, { + Map? body, + }) async { + return request>( + path, + method: 'POST', + data: body, + headers: {'traceparent': _generateTraceparent()}, + ); + } + + // ── Token 过期检测 ───────────────────────────────────────────── + + static void _checkTokenExpiry() { + final expiresAt = StorageManager.get(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(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()); + } +} diff --git a/lib/core/http/http_error.dart b/lib/core/http/http_error.dart new file mode 100644 index 0000000..306f795 --- /dev/null +++ b/lib/core/http/http_error.dart @@ -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() + .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, + }); +} diff --git a/lib/core/http/interceptors/auth_interceptor.dart b/lib/core/http/interceptors/auth_interceptor.dart new file mode 100644 index 0000000..e75e6a6 --- /dev/null +++ b/lib/core/http/interceptors/auth_interceptor.dart @@ -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 Function(); + +class AuthInterceptor extends Interceptor { + final OnLogout? onLogout; + bool _isRefreshing = false; + bool _logoutScheduled = false; + final List> _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(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(StorageKeys.refreshToken); + + // 没有 refreshToken → 静默登出 + if (refreshToken == null || refreshToken.isEmpty) { + await _logout(); + return handler.reject(_silentCancel(err.requestOptions)); + } + + // 已经在刷新中 → 排队等待 + if (_isRefreshing) { + final completer = Completer(); + _waitQueue.add(completer); + await completer.future; + + final newToken = StorageManager.get(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(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 _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(StorageKeys.accessToken) == null && + StorageManager.get(StorageKeys.refreshToken) == null; + } + + static String? _findStringDeep(dynamic value, List 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; + } +} diff --git a/lib/core/http/interceptors/response_interceptor.dart b/lib/core/http/interceptors/response_interceptor.dart new file mode 100644 index 0000000..35628db --- /dev/null +++ b/lib/core/http/interceptors/response_interceptor.dart @@ -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()); + } +} diff --git a/lib/core/storage/storage_manager.dart b/lib/core/storage/storage_manager.dart new file mode 100644 index 0000000..60fe5d9 --- /dev/null +++ b/lib/core/storage/storage_manager.dart @@ -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 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(String key) => _box?.get(key) as T?; + + static Future set(String key, dynamic value) async { + if (value == null) { + await _instance.delete(key); + } else { + await _instance.put(key, value); + } + } + + static Future delete(String key) async { + await _instance.delete(key); + } + + static Future clear() async { + await _instance.clear(); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..f369a72 --- /dev/null +++ b/lib/main.dart @@ -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 + } + }); +} diff --git a/lib/models/cloud_file.dart b/lib/models/cloud_file.dart new file mode 100644 index 0000000..71015f2 --- /dev/null +++ b/lib/models/cloud_file.dart @@ -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 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 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 json, List keys) { + for (final key in keys) { + final v = json[key]; + if (v != null) return v.toString(); + } + return null; + } + + static int? _extractInt(Map json, List 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 json, + List 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) { + 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) { + final found = _extractStringDeep(item, keys); + if (found != null && found.isNotEmpty) return found; + } + } + } + } + return null; + } + + static int? _extractIntDeep(Map json, List 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) { + final found = _extractIntDeep(value, keys); + if (found != null) return found; + } else if (value is List) { + for (final item in value) { + if (item is Map) { + 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; +} diff --git a/lib/models/media_library.dart b/lib/models/media_library.dart new file mode 100644 index 0000000..d97526e --- /dev/null +++ b/lib/models/media_library.dart @@ -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 json) { + return MediaLibrarySource( + id: + json['id']?.toString() ?? + DateTime.now().microsecondsSinceEpoch.toString(), + rootID: json['rootID']?.toString(), + path: json['path']?.toString() ?? '未配置目录', + ); + } + + Map toJson() => {'id': id, 'rootID': rootID, 'path': path}; +} + +class MediaLibraryDefinition { + final String id; + final String name; + final List 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? 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 json) { + final rawSources = json['sources']; + final sources = rawSources is List + ? rawSources + .whereType() + .map( + (item) => MediaLibrarySource.fromJson( + Map.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 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 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 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 items) { + var total = 0; + var movies = 0; + var series = 0; + var unmatched = 0; + final collections = {}; + 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().fold( + 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 on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart new file mode 100644 index 0000000..fc57ffb --- /dev/null +++ b/lib/pages/login_page.dart @@ -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 createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + 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, + ), + ), + ), + ); + } +} diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart new file mode 100644 index 0000000..b91283f --- /dev/null +++ b/lib/pages/media_library_page.dart @@ -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 createState() => _MediaLibraryPageState(); +} + +class _MediaLibraryPageState extends ConsumerState { + String _tmdbApiKey = StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; + bool _showApiKeyInput = false; + bool _tmdbSearching = false; + String? _tmdbError; + List> _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 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( + 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 _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() + .where( + (item) => + item['media_type'] == 'movie' || item['media_type'] == 'tv', + ) + .map((item) => Map.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( + 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), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart new file mode 100644 index 0000000..b3fc24b --- /dev/null +++ b/lib/pages/settings_page.dart @@ -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 createState() => _SettingsDialogState(); +} + +class _SettingsDialogState extends ConsumerState { + final _tmdbApiKeyController = TextEditingController(); + final _tmdbProxyHostController = TextEditingController(); + final _tmdbProxyPortController = TextEditingController(); + + @override + void initState() { + super.initState(); + _tmdbApiKeyController.text = + StorageManager.get(StorageKeys.tmdbApiKey) ?? ''; + _tmdbProxyHostController.text = + StorageManager.get(StorageKeys.tmdbProxyHost) ?? ''; + _tmdbProxyPortController.text = + StorageManager.get(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( + 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, + ], + ), + ); + } +} diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart new file mode 100644 index 0000000..da75d57 --- /dev/null +++ b/lib/pages/workspace_page.dart @@ -0,0 +1,1623 @@ +import 'dart:io'; + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../app/app_theme.dart'; +import '../models/cloud_file.dart'; +import '../models/media_library.dart'; +import '../providers/auth_provider.dart'; +import '../providers/file_provider.dart'; +import '../providers/media_library_provider.dart'; +import '../widgets/breadcrumb_bar.dart'; +import '../widgets/file_icon.dart'; +import '../widgets/file_list_tile.dart'; +import '../widgets/side_panel.dart'; +import '../widgets/sort_menu.dart'; +import 'media_library_page.dart'; +import 'settings_page.dart'; + +enum WorkspaceMode { cloud, media } + +enum _PaneLayoutMode { single, dual } + +enum _PaneIdentity { primary, secondary } + +class _DraggedCloudFiles { + final List files; + final _PaneIdentity source; + + const _DraggedCloudFiles(this.files, this.source); +} + +class WorkspacePage extends ConsumerStatefulWidget { + const WorkspacePage({super.key}); + + @override + ConsumerState createState() => _WorkspacePageState(); +} + +class _WorkspacePageState extends ConsumerState { + final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); + WorkspaceMode _mode = WorkspaceMode.cloud; + bool _isSidePanelOpen = false; + bool _searchOpen = false; + String _cloudSearchQuery = ''; + String _mediaSearchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fp = ref.watch(fileProvider); + + return Scaffold( + backgroundColor: Colors.transparent, + body: OS26Surface( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + _mode == WorkspaceMode.cloud + ? _CloudSidebar( + state: fp, + onSection: (section) => + ref.read(fileProvider.notifier).setSection(section), + onSettings: () => _showSettings(context), + onSignOut: () => + ref.read(authProvider.notifier).signOut(), + ) + : _MediaSidebar( + onCreate: () => + MediaLibraryPage.showCreateDialog(context, ref), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + children: [ + _TopBar( + mode: _mode, + onModeChanged: (mode) { + setState(() => _mode = mode); + if (mode == WorkspaceMode.media) { + ref.read(mediaLibraryProvider.notifier).api = ref + .read(authProvider.notifier) + .api; + ref.read(mediaLibraryProvider.notifier).load(); + } + }, + searchController: _searchController, + searchFocusNode: _searchFocusNode, + searchOpen: _searchOpen, + onSearch: (value) { + final query = value.trim(); + if (_mode == WorkspaceMode.media) { + setState(() => _mediaSearchQuery = query); + ref + .read(mediaLibraryProvider.notifier) + .setSearchQuery(query); + } else { + setState(() => _cloudSearchQuery = query); + } + }, + onToggleSearch: () { + setState(() => _searchOpen = !_searchOpen); + if (!_searchOpen) { + _searchController.clear(); + _cloudSearchQuery = ''; + _mediaSearchQuery = ''; + ref + .read(mediaLibraryProvider.notifier) + .setSearchQuery(''); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + } + }, + ), + const SizedBox(height: 14), + Expanded( + child: _mode == WorkspaceMode.cloud + ? _CloudWorkspace( + state: fp, + searchQuery: _cloudSearchQuery, + sidePanelOpen: _isSidePanelOpen, + onToggleSidePanel: () => setState( + () => _isSidePanelOpen = !_isSidePanelOpen, + ), + ) + : OS26Glass( + radius: 18, + opacity: 0.42, + padding: EdgeInsets.zero, + child: MediaLibraryPage( + showLibrarySidebar: false, + searchTitle: _mediaSearchQuery.isEmpty + ? null + : '影视资源搜索:$_mediaSearchQuery', + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _showSettings(BuildContext context) { + showDialog(context: context, builder: (_) => const SettingsDialog()); + } +} + +class _TopBar extends StatelessWidget { + final WorkspaceMode mode; + final ValueChanged onModeChanged; + final TextEditingController searchController; + final FocusNode searchFocusNode; + final bool searchOpen; + final ValueChanged onSearch; + final VoidCallback onToggleSearch; + + const _TopBar({ + required this.mode, + required this.onModeChanged, + required this.searchController, + required this.searchFocusNode, + required this.searchOpen, + required this.onSearch, + required this.onToggleSearch, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return SizedBox( + height: 42, + child: Row( + children: [ + const SizedBox(width: 78), + const Expanded(child: DragToMoveArea(child: SizedBox.expand())), + OS26Glass( + radius: 13, + opacity: 0.42, + padding: const EdgeInsets.all(3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _SegmentButton( + icon: Icons.folder_rounded, + label: '光鸭云盘', + selected: mode == WorkspaceMode.cloud, + onTap: () => onModeChanged(WorkspaceMode.cloud), + ), + _SegmentButton( + icon: Icons.movie_rounded, + label: '光鸭影视', + selected: mode == WorkspaceMode.media, + onTap: () => onModeChanged(WorkspaceMode.media), + ), + ], + ), + ), + const Expanded(child: DragToMoveArea(child: SizedBox.expand())), + AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: searchOpen ? 280 : 42, + height: 42, + child: OS26Glass( + radius: 21, + opacity: 0.52, + padding: searchOpen + ? const EdgeInsets.symmetric(horizontal: 12) + : EdgeInsets.zero, + child: searchOpen + ? Row( + children: [ + Icon(Icons.search_rounded, size: 18, color: cs.foreground), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: searchController, + focusNode: searchFocusNode, + decoration: InputDecoration( + border: InputBorder.none, + isDense: true, + hintText: mode == WorkspaceMode.cloud + ? '搜索文件' + : '搜索影视资源', + ), + textInputAction: TextInputAction.search, + onSubmitted: onSearch, + ), + ), + InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onToggleSearch, + child: Icon( + Icons.close_rounded, + size: 18, + color: cs.mutedForeground, + ), + ), + ], + ) + : InkWell( + borderRadius: BorderRadius.circular(21), + onTap: onToggleSearch, + child: Icon(Icons.search_rounded, color: cs.foreground), + ), + ), + ), + ], + ), + ); + } +} + +class _SegmentButton extends StatelessWidget { + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + const _SegmentButton({ + required this.icon, + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 126, + height: 32, + decoration: BoxDecoration( + color: selected + ? Colors.white.withValues(alpha: 0.74) + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: selected + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 15, + color: selected ? cs.foreground : cs.mutedForeground, + ), + const SizedBox(width: 7), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + color: selected ? cs.foreground : cs.mutedForeground, + ), + ), + ], + ), + ), + ); + } +} + +class _CloudSidebar extends StatelessWidget { + final FileState state; + final ValueChanged onSection; + final VoidCallback onSettings; + final VoidCallback onSignOut; + + const _CloudSidebar({ + required this.state, + required this.onSection, + required this.onSettings, + required this.onSignOut, + }); + + @override + Widget build(BuildContext context) { + final sections = WorkspaceSection.values + .where((section) => section != WorkspaceSection.mediaLibrary) + .toList(); + return SizedBox( + width: 250, + child: OS26Glass( + radius: 24, + opacity: 0.56, + padding: const EdgeInsets.fromLTRB(14, 18, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SidebarBrand( + icon: Icons.cloud_sync_rounded, + title: '光鸭云盘', + subtitle: 'Cloud Workspace', + ), + const SizedBox(height: 18), + Expanded( + child: ListView( + children: [ + for (final section in sections) + _SidebarTile( + icon: _sectionIcon(section), + label: section.label, + selected: state.section == section, + onTap: () => onSection(section), + ), + const Divider(height: 24), + _SidebarTile( + icon: Icons.manage_search_rounded, + label: '文件扫描与清理', + selected: false, + onTap: () {}, + ), + _SidebarTile( + icon: Icons.text_fields_rounded, + label: '批量重命名', + selected: false, + onTap: () {}, + ), + _SidebarTile( + icon: Icons.bolt_rounded, + label: '秒传工具', + selected: false, + onTap: () {}, + ), + ], + ), + ), + _SidebarTile( + icon: Icons.settings_rounded, + label: '工作区设置', + selected: false, + onTap: onSettings, + ), + _SidebarTile( + icon: Icons.logout_rounded, + label: '退出登录', + selected: false, + onTap: onSignOut, + ), + ], + ), + ), + ); + } + + IconData _sectionIcon(WorkspaceSection s) { + switch (s) { + case WorkspaceSection.files: + return Icons.folder_rounded; + case WorkspaceSection.recentViewed: + return Icons.access_time_rounded; + case WorkspaceSection.recentRestored: + return Icons.history_rounded; + case WorkspaceSection.photos: + return Icons.image_rounded; + case WorkspaceSection.videos: + return Icons.smart_display_rounded; + case WorkspaceSection.audio: + return Icons.music_note_rounded; + case WorkspaceSection.documents: + return Icons.description_rounded; + case WorkspaceSection.cloud: + return Icons.cloud_download_rounded; + case WorkspaceSection.shares: + return Icons.ios_share_rounded; + case WorkspaceSection.recycle: + return Icons.delete_outline_rounded; + case WorkspaceSection.mediaLibrary: + return Icons.movie_filter_rounded; + } + } +} + +class _MediaSidebar extends ConsumerWidget { + final VoidCallback onCreate; + + const _MediaSidebar({required this.onCreate}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(mediaLibraryProvider); + final notifier = ref.read(mediaLibraryProvider.notifier); + return SizedBox( + width: 250, + child: OS26Glass( + radius: 24, + opacity: 0.56, + padding: const EdgeInsets.fromLTRB(14, 18, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SidebarBrand( + icon: Icons.play_circle_fill_rounded, + title: '光鸭影视', + subtitle: 'Media Center', + ), + const SizedBox(height: 18), + _SidebarTile( + icon: Icons.home_rounded, + label: '首页', + selected: true, + onTap: () {}, + ), + _SidebarTile( + icon: Icons.movie_creation_rounded, + label: '电影', + count: state.statistics.movies, + selected: false, + onTap: () {}, + ), + _SidebarTile( + icon: Icons.live_tv_rounded, + label: '电视剧', + count: state.statistics.series, + selected: false, + onTap: () {}, + ), + _SidebarTile( + icon: Icons.help_outline_rounded, + label: '未识别', + count: state.statistics.unmatched, + selected: false, + onTap: () {}, + ), + const Padding( + padding: EdgeInsets.fromLTRB(8, 18, 8, 8), + child: Text( + '媒体库', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + ), + Expanded( + child: ListView( + children: [ + for (final library in state.libraries) + _SidebarTile( + icon: library.kind == MediaLibraryKind.series + ? Icons.live_tv_rounded + : Icons.smart_display_rounded, + label: library.name, + selected: state.selectedLibrary?.id == library.id, + onTap: () => notifier.selectLibrary(library.id), + ), + ], + ), + ), + _SidebarTile( + icon: Icons.add_rounded, + label: '新建媒体库', + selected: false, + onTap: onCreate, + ), + _SidebarTile( + icon: Icons.auto_fix_high_rounded, + label: 'TMDB 整理', + selected: false, + onTap: () {}, + ), + _SidebarTile( + icon: Icons.category_rounded, + label: '分类管理', + selected: false, + onTap: () {}, + ), + ], + ), + ), + ); + } +} + +class _SidebarBrand extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + + const _SidebarBrand({ + required this.icon, + required this.title, + required this.subtitle, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: cs.primary, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: cs.primary.withValues(alpha: 0.24), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: Icon(icon, color: Colors.white, size: 26), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + color: cs.foreground, + ), + ), + Text( + subtitle, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.6, + color: cs.mutedForeground, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _SidebarTile extends StatelessWidget { + final IconData icon; + final String label; + final int? count; + final bool selected; + final VoidCallback onTap; + + const _SidebarTile({ + required this.icon, + required this.label, + this.count, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 9), + decoration: BoxDecoration( + color: selected + ? const Color(0xFFFFB18A).withValues(alpha: 0.72) + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: selected + ? Colors.white.withValues(alpha: 0.36) + : const Color(0xFFFFE4D2).withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 17, color: cs.primary), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w800 : FontWeight.w600, + color: selected ? cs.foreground : cs.mutedForeground, + ), + ), + ), + if (count != null) + Text( + count.toString(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: cs.mutedForeground, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _CloudWorkspace extends ConsumerStatefulWidget { + final FileState state; + final String searchQuery; + final bool sidePanelOpen; + final VoidCallback onToggleSidePanel; + + const _CloudWorkspace({ + required this.state, + required this.searchQuery, + required this.sidePanelOpen, + required this.onToggleSidePanel, + }); + + @override + ConsumerState<_CloudWorkspace> createState() => _CloudWorkspaceState(); +} + +class _CloudWorkspaceState extends ConsumerState<_CloudWorkspace> { + _PaneLayoutMode _paneMode = _PaneLayoutMode.dual; + + @override + Widget build(BuildContext context) { + final state = widget.state; + final query = widget.searchQuery.trim().toLowerCase(); + final searchFiles = query.isEmpty + ? state.files + : state.files + .where((file) => + file.name.toLowerCase().contains(query) || + file.cloudPath.toLowerCase().contains(query)) + .toList(); + return Row( + children: [ + Expanded( + child: OS26Glass( + radius: 18, + opacity: 0.42, + padding: const EdgeInsets.all(14), + child: Column( + children: [ + _CloudToolbar( + state: state, + paneMode: _paneMode, + onPaneModeChanged: (mode) => setState(() => _paneMode = mode), + sidePanelOpen: widget.sidePanelOpen, + onToggleSidePanel: widget.onToggleSidePanel, + ), + const SizedBox(height: 12), + Expanded( + child: query.isNotEmpty + ? _PrimaryFilePane( + title: '文件搜索:${widget.searchQuery}', + state: state, + overrideFiles: searchFiles, + ) + : state.section == WorkspaceSection.files + ? _paneMode == _PaneLayoutMode.dual + ? Row( + children: [ + Expanded( + child: _PrimaryFilePane( + title: '左侧面板', + state: state, + ), + ), + const SizedBox(width: 10), + const Expanded(child: _SecondaryFilePane()), + ], + ) + : _PrimaryFilePane(title: '文件列表', state: state) + : _PrimaryFilePane( + title: state.section.label, + state: state, + ), + ), + _CloudStatusBar(state: state), + ], + ), + ), + ), + if (widget.sidePanelOpen) ...[ + const SizedBox(width: 12), + SizedBox( + width: 280, + child: OS26Glass( + radius: 18, + opacity: 0.48, + padding: EdgeInsets.zero, + child: const SidePanel(), + ), + ), + ], + ], + ); + } +} + +class _CloudToolbar extends ConsumerWidget { + final FileState state; + final _PaneLayoutMode paneMode; + final ValueChanged<_PaneLayoutMode> onPaneModeChanged; + final bool sidePanelOpen; + final VoidCallback onToggleSidePanel; + + const _CloudToolbar({ + required this.state, + required this.paneMode, + required this.onPaneModeChanged, + required this.sidePanelOpen, + required this.onToggleSidePanel, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(fileProvider.notifier); + return Row( + children: [ + Expanded( + child: BreadcrumbBar( + path: state.folderPath, + onNavigate: notifier.navigateToPathIndex, + ), + ), + SortMenu( + currentSort: state.serverSort, + currentDirection: state.serverSortDirection, + onSortChanged: notifier.setSort, + ), + const SizedBox(width: 8), + _ToolbarSegment(value: paneMode, onChanged: onPaneModeChanged), + const SizedBox(width: 8), + _ToolbarButton( + icon: Icons.upload_rounded, + label: '上传', + primary: true, + onTap: () => _pickAndUpload(ref), + ), + const SizedBox(width: 6), + _ToolbarButton( + icon: Icons.create_new_folder_rounded, + label: '新建文件夹', + onTap: () => _showCreateFolderDialog(context, ref), + ), + _ToolbarButton( + icon: Icons.refresh_rounded, + label: '刷新', + onTap: () => notifier.loadFiles(), + ), + _ToolbarButton( + icon: Icons.more_horiz_rounded, + label: sidePanelOpen ? '隐藏详情' : '显示详情', + onTap: onToggleSidePanel, + ), + ], + ); + } + + Future _pickAndUpload(WidgetRef ref) async { + final result = await FilePicker.pickFiles(allowMultiple: true); + if (result == null) return; + final files = result.paths.whereType().map(File.new).toList(); + await ref.read(fileProvider.notifier).uploadLocalFiles(files); + } + + void _showCreateFolderDialog(BuildContext context, WidgetRef ref) { + final controller = TextEditingController(); + showShadDialog( + context: context, + builder: (ctx) => ShadDialog( + title: const Text('新建文件夹'), + actions: [ + ShadButton.outline( + child: const Text('取消'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ShadButton( + child: const Text('创建'), + onPressed: () { + final name = controller.text.trim(); + if (name.isNotEmpty) { + ref.read(fileProvider.notifier).createFolder(name); + Navigator.of(ctx).pop(); + } + }, + ), + ], + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: ShadInput( + controller: controller, + placeholder: const Text('文件夹名称'), + autofocus: true, + ), + ), + ), + ); + } +} + +class _ToolbarSegment extends StatelessWidget { + final _PaneLayoutMode value; + final ValueChanged<_PaneLayoutMode> onChanged; + + const _ToolbarSegment({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return OS26Glass( + radius: 10, + opacity: 0.36, + padding: const EdgeInsets.all(2), + child: Row( + children: [ + _ToolbarSegmentButton( + icon: Icons.view_agenda_rounded, + selected: value == _PaneLayoutMode.single, + tooltip: '单面板', + onTap: () => onChanged(_PaneLayoutMode.single), + ), + _ToolbarSegmentButton( + icon: Icons.view_column_rounded, + selected: value == _PaneLayoutMode.dual, + tooltip: '双面板', + onTap: () => onChanged(_PaneLayoutMode.dual), + ), + ], + ), + ); + } +} + +class _ToolbarSegmentButton extends StatelessWidget { + final IconData icon; + final bool selected; + final String tooltip; + final VoidCallback onTap; + + const _ToolbarSegmentButton({ + required this.icon, + required this.selected, + required this.tooltip, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return ShadTooltip( + builder: (_) => Text(tooltip), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onTap, + child: Container( + width: 32, + height: 28, + decoration: BoxDecoration( + color: selected + ? Colors.white.withValues(alpha: 0.68) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + size: 17, + color: selected ? cs.primary : cs.mutedForeground, + ), + ), + ), + ); + } +} + +class _ToolbarButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + final bool primary; + + const _ToolbarButton({ + required this.icon, + required this.label, + required this.onTap, + this.primary = false, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return ShadTooltip( + builder: (_) => Text(label), + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Container( + constraints: BoxConstraints(minWidth: primary ? 72 : 38), + height: 32, + padding: EdgeInsets.symmetric(horizontal: primary ? 10 : 0), + decoration: BoxDecoration( + color: primary ? cs.primary : Colors.white.withValues(alpha: 0.46), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: primary ? cs.primary : Colors.white.withValues(alpha: 0.54), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 18, + color: primary ? cs.primaryForeground : cs.mutedForeground, + ), + if (primary) ...[ + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: cs.primaryForeground, + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _PrimaryFilePane extends ConsumerWidget { + final String title; + final FileState state; + final List? overrideFiles; + + const _PrimaryFilePane({ + required this.title, + required this.state, + this.overrideFiles, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.read(fileProvider.notifier); + final files = overrideFiles ?? state.files; + return _FilePaneFrame( + title: title, + itemCount: files.length, + isLoading: state.isLoading, + errorMessage: state.errorMessage, + emptyLabel: '没有文件', + breadcrumbPath: state.folderPath, + onBreadcrumbNavigate: (index) => + ref.read(fileProvider.notifier).navigateToPathIndex(index), + header: const _FilePaneHeader(), + dropParentID: state.folderPath.isEmpty ? null : state.folderPath.last.id, + onMoveCloudFiles: (files, parentID) => + notifier.moveFilesTo(files, parentID: parentID), + onUploadLocalFiles: (files, parentID) => + notifier.uploadLocalFiles(files, parentID: parentID), + child: RefreshIndicator( + onRefresh: () => notifier.loadFiles(), + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: files.length, + itemBuilder: (context, index) { + final file = files[index]; + final selected = state.selectedIDs.contains(file.id); + final tile = FileListTile( + file: file, + isSelected: selected, + onSelect: () { + if (file.isDirectory && !selected) { + notifier.navigateToFolder(file); + } else { + notifier.toggleSelection(file.id); + } + }, + onOpen: file.isDirectory + ? () => notifier.navigateToFolder(file) + : () => notifier.downloadFile(file), + onRename: () {}, + onCopy: () => notifier.copyToClipboard([file]), + onCut: () => notifier.cutToClipboard([file]), + onDownload: () => notifier.downloadFile(file), + onShare: () {}, + onDelete: () => notifier.deleteFiles([file]), + ); + return Draggable<_DraggedCloudFiles>( + data: _DraggedCloudFiles([file], _PaneIdentity.primary), + feedback: _DragFeedback(label: file.name), + childWhenDragging: Opacity(opacity: 0.35, child: tile), + child: tile, + ); + }, + ), + ), + ); + } +} + +class _SecondaryFilePane extends ConsumerStatefulWidget { + const _SecondaryFilePane(); + + @override + ConsumerState<_SecondaryFilePane> createState() => _SecondaryFilePaneState(); +} + +class _SecondaryFilePaneState extends ConsumerState<_SecondaryFilePane> { + final _path = []; + var _files = []; + var _loading = false; + String? _error; + + @override + void initState() { + super.initState(); + Future.microtask(_load); + } + + Future _load({String? parentID}) async { + setState(() { + _loading = true; + _error = null; + }); + try { + final api = ref.read(authProvider.notifier).api; + final result = await api.fsFiles(parentID: parentID, pageSize: 50); + setState(() => _files = _extractFiles(result)); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + String? get _currentParentID => _path.isEmpty ? null : _path.last.id; + + Future _moveCloudFiles(List files, String? parentID) async { + if (files.isEmpty) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final api = ref.read(authProvider.notifier).api; + await api.fsMove(files.map((file) => file.id).toList(), parentID: parentID); + await _load(parentID: _currentParentID); + await ref.read(fileProvider.notifier).loadFiles(); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _uploadLocalFiles(List files, String? parentID) async { + if (files.isEmpty) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final api = ref.read(authProvider.notifier).api; + for (final file in files) { + if (await file.exists()) { + await api.fileUpload(file, parentID: parentID); + } + } + await _load(parentID: _currentParentID); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return _FilePaneFrame( + title: _path.isEmpty ? '右侧面板' : _path.last.name, + itemCount: _files.length, + isLoading: _loading, + errorMessage: _error, + emptyLabel: '没有文件', + breadcrumbPath: _path, + onBreadcrumbNavigate: _navigateBreadcrumb, + dropParentID: _currentParentID, + onMoveCloudFiles: _moveCloudFiles, + onUploadLocalFiles: _uploadLocalFiles, + header: Row( + children: [ + if (_path.isNotEmpty) + _ToolbarButton( + icon: Icons.arrow_back_rounded, + label: '返回', + onTap: () { + setState(() => _path.removeLast()); + _load(parentID: _path.isEmpty ? null : _path.last.id); + }, + ), + const Expanded(child: _FilePaneHeader()), + ], + ), + child: RefreshIndicator( + onRefresh: () => _load(parentID: _currentParentID), + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: _files.length, + itemBuilder: (context, index) { + final file = _files[index]; + final row = FileListTile( + file: file, + onSelect: () { + if (file.isDirectory) { + setState(() => _path.add(file)); + _load(parentID: file.id); + } else { + ref.read(fileProvider.notifier).downloadFile(file); + } + }, + onOpen: () { + if (file.isDirectory) { + setState(() => _path.add(file)); + _load(parentID: file.id); + } else { + ref.read(fileProvider.notifier).downloadFile(file); + } + }, + onCopy: () => ref.read(fileProvider.notifier).copyToClipboard([file]), + onCut: () => ref.read(fileProvider.notifier).cutToClipboard([file]), + onDownload: () => ref.read(fileProvider.notifier).downloadFile(file), + onDelete: () => ref.read(fileProvider.notifier).deleteFiles([file]), + ); + return Draggable<_DraggedCloudFiles>( + data: _DraggedCloudFiles([file], _PaneIdentity.secondary), + feedback: _DragFeedback(label: file.name), + childWhenDragging: Opacity(opacity: 0.35, child: row), + child: row, + ); + }, + ), + ), + ); + } + + List _extractFiles(Map json) { + final result = []; + final seen = {}; + void visit(dynamic value) { + if (value is Map) { + try { + final file = CloudFile.fromJson(Map.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; + } + + void _navigateBreadcrumb(int index) { + if (index < 0) { + setState(() => _path.clear()); + _load(); + return; + } + if (!_path.asMap().containsKey(index)) return; + setState(() => _path.removeRange(index + 1, _path.length)); + _load(parentID: _path.isEmpty ? null : _path.last.id); + } +} + +class _FilePaneFrame extends StatelessWidget { + final String title; + final int itemCount; + final bool isLoading; + final String? errorMessage; + final String emptyLabel; + final String? dropParentID; + final List breadcrumbPath; + final ValueChanged? onBreadcrumbNavigate; + final Future Function(List files, String? parentID)? + onMoveCloudFiles; + final Future Function(List files, String? parentID)? + onUploadLocalFiles; + final Widget header; + final Widget child; + + const _FilePaneFrame({ + required this.title, + required this.itemCount, + required this.isLoading, + required this.errorMessage, + required this.emptyLabel, + this.dropParentID, + this.breadcrumbPath = const [], + this.onBreadcrumbNavigate, + this.onMoveCloudFiles, + this.onUploadLocalFiles, + required this.header, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return _PaneDropSurface( + parentID: dropParentID, + onMoveCloudFiles: onMoveCloudFiles, + onUploadLocalFiles: onUploadLocalFiles, + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.42), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.62)), + ), + child: Column( + children: [ + Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + color: cs.foreground, + ), + ), + const SizedBox(width: 8), + Text( + '$itemCount 项', + style: TextStyle(fontSize: 11, color: cs.mutedForeground), + ), + ], + ), + ), + Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), + SizedBox( + height: 36, + child: BreadcrumbBar( + path: breadcrumbPath, + onNavigate: onBreadcrumbNavigate ?? (_) {}, + ), + ), + Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), + SizedBox(height: 34, child: header), + Divider(height: 1, color: cs.border.withValues(alpha: 0.62)), + Expanded( + child: isLoading + ? const _ShadLoading() + : errorMessage != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + errorMessage!, + textAlign: TextAlign.center, + style: TextStyle(color: cs.destructive), + ), + ), + ) + : itemCount == 0 + ? Center( + child: Text( + emptyLabel, + style: TextStyle(color: cs.mutedForeground), + ), + ) + : child, + ), + ], + ), + ), + ); + } +} + +class _ShadLoading extends StatelessWidget { + const _ShadLoading(); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Center( + child: OS26Glass( + radius: 12, + opacity: 0.52, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ShadProgress(), + const SizedBox(height: 10), + Text( + '加载中', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: cs.mutedForeground, + ), + ), + ], + ), + ), + ); + } +} + +class _PaneDropSurface extends StatefulWidget { + final String? parentID; + final Future Function(List files, String? parentID)? + onMoveCloudFiles; + final Future Function(List files, String? parentID)? + onUploadLocalFiles; + final Widget child; + + const _PaneDropSurface({ + required this.parentID, + required this.onMoveCloudFiles, + required this.onUploadLocalFiles, + required this.child, + }); + + @override + State<_PaneDropSurface> createState() => _PaneDropSurfaceState(); +} + +class _PaneDropSurfaceState extends State<_PaneDropSurface> { + var _active = false; + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return DropTarget( + onDragEntered: (_) => setState(() => _active = true), + onDragExited: (_) => setState(() => _active = false), + onDragDone: (details) async { + setState(() => _active = false); + final files = details.files + .map((file) => file.path) + .where((path) => path.isNotEmpty) + .map(File.new) + .where((file) => file.existsSync()) + .toList(); + if (files.isNotEmpty) { + await widget.onUploadLocalFiles?.call(files, widget.parentID); + } + }, + child: DragTarget<_DraggedCloudFiles>( + onWillAcceptWithDetails: (_) => widget.onMoveCloudFiles != null, + onAcceptWithDetails: (details) async { + setState(() => _active = false); + await widget.onMoveCloudFiles?.call(details.data.files, widget.parentID); + }, + onMove: (_) { + if (!_active) setState(() => _active = true); + }, + onLeave: (_) => setState(() => _active = false), + builder: (context, candidates, rejected) { + final active = _active || candidates.isNotEmpty; + return Stack( + children: [ + widget.child, + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: active ? 1 : 0, + duration: const Duration(milliseconds: 120), + child: Container( + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: cs.primary, width: 1.5), + ), + child: Center( + child: OS26Glass( + radius: 12, + opacity: 0.7, + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + child: Text( + '松开以上传或移动到此面板', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + color: cs.primary, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _DragFeedback extends StatelessWidget { + final String label; + + const _DragFeedback({required this.label}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: OS26Glass( + radius: 10, + opacity: 0.78, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.insert_drive_file_rounded, size: 16), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + ), + ], + ), + ), + ); + } +} + +class _FilePaneHeader extends StatelessWidget { + const _FilePaneHeader(); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + final style = TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: cs.mutedForeground, + ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + const SizedBox(width: 44), + Expanded(child: Text('名称', style: style)), + SizedBox( + width: 88, + child: Text('大小', textAlign: TextAlign.right, style: style), + ), + const SizedBox(width: 12), + SizedBox( + width: 116, + child: Text('修改时间', textAlign: TextAlign.right, style: style), + ), + ], + ), + ); + } +} + +class _CloudStatusBar extends StatelessWidget { + final FileState state; + + const _CloudStatusBar({required this.state}); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return Container( + height: 40, + margin: const EdgeInsets.only(top: 10), + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.38), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.insert_drive_file_rounded, + size: 15, + color: cs.mutedForeground, + ), + const SizedBox(width: 6), + Text( + '本页文件 ${state.files.where((file) => !file.isDirectory).length}', + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), + const SizedBox(width: 14), + Icon(Icons.folder_rounded, size: 15, color: cs.mutedForeground), + const SizedBox(width: 6), + Text( + '本页文件夹 ${state.files.where((file) => file.isDirectory).length}', + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), + const Spacer(), + if (state.errorMessage != null) + Text( + state.errorMessage!, + style: TextStyle(fontSize: 12, color: cs.destructive), + overflow: TextOverflow.ellipsis, + ) + else if (state.statusMessage != null) + Text( + state.statusMessage!, + style: TextStyle(fontSize: 12, color: cs.primary), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart new file mode 100644 index 0000000..9756fd2 --- /dev/null +++ b/lib/providers/auth_provider.dart @@ -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? 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? 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? json, List 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) { + final found = + _findStringDeep(entry.value as Map, keys); + if (found != null) return found; + } + } + return null; + } + + static int? _findInt64Deep( + Map? json, List 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 { + final GuangyaAPI _api = GuangyaAPI( + deviceID: StorageManager.get(StorageKeys.deviceID), + ); + Timer? _qrPollingTimer; + Timer? _countdownTimer; + + GuangyaAPI get api => _api; + + AuthNotifier() : super(const AuthState()) { + _api.accessToken = ''; + tryRestoreSession(); + } + + Future tryRestoreSession() async { + final access = StorageManager.get(StorageKeys.accessToken) ?? ''; + final refresh = StorageManager.get(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 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 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 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 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 signOut() async { + _api.clearTokens(); + await _clearTokens(); + _qrPollingTimer?.cancel(); + _countdownTimer?.cancel(); + state = const AuthState(isLoading: false); + } + + // ── Token persistence ───────────────────────────────────────────── + + Future _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 _clearTokens() async { + await StorageManager.delete(StorageKeys.accessToken); + await StorageManager.delete(StorageKeys.refreshToken); + } + + @override + void dispose() { + _qrPollingTimer?.cancel(); + _countdownTimer?.cancel(); + super.dispose(); + } +} + +final authProvider = StateNotifierProvider( + (ref) => AuthNotifier(), +); diff --git a/lib/providers/file_provider.dart b/lib/providers/file_provider.dart new file mode 100644 index 0000000..0c4fd40 --- /dev/null +++ b/lib/providers/file_provider.dart @@ -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 files; + final List 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 selectedIDs; + final List? 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? files, + List? folderPath, + bool? isLoading, + int? currentPage, + int? pageSize, + int? totalPages, + FileSort? serverSort, + SortDirection? serverSortDirection, + String? errorMessage, + bool clearError = false, + String? statusMessage, + bool clearStatus = false, + Set? selectedIDs, + List? 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 { + 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 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> _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': [], 'total': 0}, + }; + } + } + + Future navigateToFolder(CloudFile folder) async { + final newPath = [...state.folderPath, folder]; + state = state.copyWith( + folderPath: newPath, + currentPage: 0, + selectedIDs: {}, + ); + await loadFiles(parentID: folder.id); + } + + Future navigateBack() async { + if (state.folderPath.isEmpty) return; + final newPath = List.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.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 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 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 deleteFiles(List 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 recycleFiles(List 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 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 files) { + state = state.copyWith(clipboard: files, clipboardIsMove: false); + } + + void cutToClipboard(List files) { + state = state.copyWith(clipboard: files, clipboardIsMove: true); + } + + Future 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 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 uploadLocalFiles(List 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 moveFilesTo(List 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 _extractFiles(Map json) { + final result = []; + final seen = {}; + + void visit(dynamic value) { + if (value is Map) { + final map = Map.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 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? _findArrayDeep( + Map json, + List 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.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.from(v), keys); + if (found != null) return found; + } + } + return null; + } + + static int? _extractInt(Map json, List 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) { + final found = _extractInt(entry.value as Map, keys); + if (found != null) return found; + } + } + return null; + } + + static String? _findStringDeep(Map json, List 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) { + final found = _findStringDeep( + entry.value as Map, + keys, + ); + if (found != null) return found; + } + } + return null; + } +} + +final fileProvider = StateNotifierProvider( + (ref) => FileNotifier(), +); diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart new file mode 100644 index 0000000..a9b1acc --- /dev/null +++ b/lib/providers/media_library_provider.dart @@ -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 libraries; + final String? selectedLibraryID; + final List 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 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? libraries, + String? selectedLibraryID, + bool clearSelectedLibrary = false, + List? 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 { + GuangyaAPI? _api; + bool _loaded = false; + bool _cancelScan = false; + + MediaLibraryNotifier() : super(const MediaLibraryState()); + + set api(GuangyaAPI value) => _api = value; + + Future 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 + ? [] + : _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 selectLibrary(String id) async { + state = state.copyWith( + selectedLibraryID: id, + items: _loadItems(id), + searchQuery: '', + clearError: true, + ); + } + + Future 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 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 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 = []; + 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 = {}; + 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> _scanSource( + String? rootID, + String rootPath, { + required bool recursive, + required int minimumSizeBytes, + }) async { + final folders = <_ScanFolder>[_ScanFolder(rootID, rootPath)]; + final visited = {}; + final mediaFiles = []; + + 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 _extractFiles(Map json) { + final result = []; + final seen = {}; + + void appendList(List values) { + for (final value in values) { + if (value is Map) { + try { + final file = CloudFile.fromJson(Map.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.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 _loadLibraries() { + final raw = StorageManager.get(StorageKeys.mediaLibraries); + if (raw is! List) return []; + return raw + .whereType() + .map( + (item) => + MediaLibraryDefinition.fromJson(Map.from(item)), + ) + .toList(); + } + + List _loadItems(String libraryID) { + return _loadAllItems() + .where((item) => item.libraryID == libraryID) + .toList(); + } + + List _loadAllItems() { + final raw = StorageManager.get(StorageKeys.mediaLibraryItems); + if (raw is! List) return []; + return raw + .whereType() + .map( + (item) => MediaLibraryItem.fromJson(Map.from(item)), + ) + .where((item) => item.file.id.isNotEmpty) + .toList(); + } + + Future _saveLibraries(List libraries) { + return StorageManager.set( + StorageKeys.mediaLibraries, + libraries.map((library) => library.toJson()).toList(), + ); + } + + Future _saveAllItems(List items) { + return StorageManager.set( + StorageKeys.mediaLibraryItems, + items.map((item) => item.toJson()).toList(), + ); + } + + static List? _findArrayDeep( + Map json, + List 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) { + 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) { + 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( + (ref) => MediaLibraryNotifier(), + ); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 0000000..df841db --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -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 { + ThemeNotifier() : super(const ThemeState()) { + _load(); + } + + void _load() { + final saved = StorageManager.get(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 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( + (ref) => ThemeNotifier(), +); diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart new file mode 100644 index 0000000..3eb40a3 --- /dev/null +++ b/lib/utils/extensions.dart @@ -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'; + } +} diff --git a/lib/widgets/breadcrumb_bar.dart b/lib/widgets/breadcrumb_bar.dart new file mode 100644 index 0000000..66add3e --- /dev/null +++ b/lib/widgets/breadcrumb_bar.dart @@ -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 path; + final ValueChanged onNavigate; + + const BreadcrumbBar({ + super.key, + required this.path, + required this.onNavigate, + }); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + + final items = [ + // 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), + ); + } +} diff --git a/lib/widgets/confirm_dialog.dart b/lib/widgets/confirm_dialog.dart new file mode 100644 index 0000000..512a766 --- /dev/null +++ b/lib/widgets/confirm_dialog.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +Future showConfirmDialog( + BuildContext context, { + required String title, + required String content, + String confirmText = '确认', + String cancelText = '取消', +}) async { + final result = await showShadDialog( + 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; +} diff --git a/lib/widgets/file_icon.dart b/lib/widgets/file_icon.dart new file mode 100644 index 0000000..901ddee --- /dev/null +++ b/lib/widgets/file_icon.dart @@ -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); + } + } +} diff --git a/lib/widgets/file_list_tile.dart b/lib/widgets/file_list_tile.dart new file mode 100644 index 0000000..dad89cd --- /dev/null +++ b/lib/widgets/file_list_tile.dart @@ -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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/side_panel.dart b/lib/widgets/side_panel.dart new file mode 100644 index 0000000..991da54 --- /dev/null +++ b/lib/widgets/side_panel.dart @@ -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 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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/sort_menu.dart b/lib/widgets/sort_menu.dart new file mode 100644 index 0000000..b009635 --- /dev/null +++ b/lib/widgets/sort_menu.dart @@ -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 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); + }, + ), + ], + ), + ), + ); + } +} diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..0f3f9ac --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -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 diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..c932698 --- /dev/null +++ b/macos/Podfile.lock @@ -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 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f97579a --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; + 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 = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 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 = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; +/* 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 = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + D9A94B78B855426E12531ED1 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* guangya_flutter.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 39D84C9718FE617BAC88B7EC /* Pods_Runner.framework */, + 6446EF046C2C6AC038CB40BD /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 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 = ""; + }; +/* 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 = ""; + }; +/* 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 */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..6432af2 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..4651c20 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = guangya_flutter + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.ptools.guangyaFlutter + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.ptools. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..cff5a4b --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..47c3c42 --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..90536f5 --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,20 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.titleVisibility = .hidden + self.titlebarAppearsTransparent = true + self.isOpaque = false + self.backgroundColor = .clear + self.styleMask.insert(.fullSizeContentView) + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..38da9a9 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..e434811 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1655 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "5b7468c326d2f8a4f630056404ca0d291ade42918f4a3c6233618e724f39da8e" + url: "https://pub.dev" + source: hosted + version: "92.0.0" + adaptive_platform_ui: + dependency: "direct main" + description: + name: adaptive_platform_ui + sha256: "72ebe76218ea43b6bef370f1daaf6f80f4abe75f4a7b75e10f1392c13a9f1ce4" + url: "https://pub.dev" + source: hosted + version: "0.1.107" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "70e4b1ef8003c64793a9e268a551a82869a8a96f39deb73dea28084b0e8bf75e" + url: "https://pub.dev" + source: hosted + version: "9.0.0" + analyzer_buffer: + dependency: transitive + description: + name: analyzer_buffer + sha256: ff4bd291778c7417fe53fe24ee0d0a1f1ffe281a2d4ea887e7094f16e36eace7 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + boxy: + dependency: transitive + description: + name: boxy + sha256: "42ccafe13b2893878042acc5b7e2446025328e11a3197b0bb78db42ff76aa3f0" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + build: + dependency: transitive + description: + name: build + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + build_config: + dependency: transitive + description: + name: build_config + sha256: f2c223156a26eea323e6244b85141d76413a80aeee9fe0b380773789fabaf8ae + url: "https://pub.dev" + source: hosted + version: "1.3.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 + url: "https://pub.dev" + source: hosted + version: "4.1.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + desktop_drop: + dependency: "direct main" + description: + name: desktop_drop + sha256: aa1e797255bfbc76f9eb5aa4f61e5b68dbf69962ab1be6495816d2f251bc0d1f + url: "https://pub.dev" + source: hosted + version: "0.7.1" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + url: "https://pub.dev" + source: hosted + version: "8.1.0" + dio: + dependency: "direct main" + description: + name: dio + sha256: ea2bad3c89a27635ce2d85cce4d6b199da49a5a48ec77b03e45b65a3b90922b0 + url: "https://pub.dev" + source: hosted + version: "5.10.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: dd58dc3861eb36edb13b217efc006a1c21e5bbc341de8c229b85634fa5e362e4 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + easy_refresh: + dependency: "direct main" + description: + name: easy_refresh + sha256: "513a5dc82c044f8a9c9a3d600b3d5d3256070575bfd8761940533d8e209ba79b" + url: "https://pub.dev" + source: hosted + version: "3.5.1" + extended_image: + dependency: transitive + description: + name: extended_image + sha256: e81d801cd692921f152834e01f39bba8b3d6302c48ad420d499c6062a7e24737 + url: "https://pub.dev" + source: hosted + version: "10.1.0" + extended_image_library: + dependency: transitive + description: + name: extended_image_library + sha256: "1f9a24d3a00c2633891c6a7b5cab2807999eb2d5b597e5133b63f49d113811fe" + url: "https://pub.dev" + source: hosted + version: "5.0.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: fdc6a37f715d19f35b131decf1ce39242eeed5ddae18c0818c3eccb731ab76be + url: "https://pub.dev" + source: hosted + version: "12.0.0-beta.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animate: + dependency: transitive + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 + url: "https://pub.dev" + source: hosted + version: "3.2.5" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_client_helper: + dependency: transitive + description: + name: http_client_helper + sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + install_plugin_v3: + dependency: "direct main" + description: + name: install_plugin_v3 + sha256: "9ada1b905a7ab406da04c8e706de1f4f8554dffcc952c57304befac8d2b37450" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "44729f5c45748e6748f6b9a57ab8f7e4336edc8ae41fc295070e3814e616a6c0" + url: "https://pub.dev" + source: hosted + version: "6.13.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + liquid_glass_easy: + dependency: "direct main" + description: + name: liquid_glass_easy + sha256: "07f7fc2d59aa6058c71db879d918e0995cb9902a96d63c08884c8af4049dde5e" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lucide_icons_flutter: + dependency: "direct main" + description: + name: lucide_icons_flutter + sha256: "234155b10641b8ef7bab8a077b93ea6c92fe849c06740cf89dcd86dcf350934f" + url: "https://pub.dev" + source: hosted + version: "3.1.15" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: transitive + description: + name: mockito + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 + url: "https://pub.dev" + source: hosted + version: "5.6.4" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + pasteboard: + dependency: "direct main" + description: + name: pasteboard + sha256: fedbe8da188d2f713aa8b01260737342e6e1087534a3ab26e1a719f8d3e8f32f + url: "https://pub.dev" + source: hosted + version: "0.5.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_drawing: + dependency: transitive + description: + name: path_drawing + sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" + url: "https://pub.dev" + source: hosted + version: "9.4.10" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: e55bc08c084a424e1bbdc303fe8ea75daafe4269b68fd0e0f6f1678413715b66 + url: "https://pub.dev" + source: hosted + version: "1.0.0-dev.9" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: "16471a1260b94e939394d78f1c63a9350936ac4a68c9fbdab40be47268c0b04f" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "6f9220534d7a353b53c875ea191a84d28cb4e52ac420a66a1bd7318329d977b0" + url: "https://pub.dev" + source: hosted + version: "4.0.3" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + serial_csv: + dependency: transitive + description: + name: serial_csv + sha256: "2d62bb70cb3ce7251383fc86ea9aae1298ab1e57af6ef4e93b6a9751c5c268dd" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + shadcn_ui: + dependency: "direct main" + description: + name: shadcn_ui + sha256: "7069c8e4e496b7ad98073bdf8267bb307a23398db7280e3f26cd4db7b1d1993f" + url: "https://pub.dev" + source: hosted + version: "0.55.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "02180b01c1237b9706b663d9402b2cf2402b3407f48cce99cc19e3200f095b8a" + url: "https://pub.dev" + source: hosted + version: "13.2.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9" + url: "https://pub.dev" + source: hosted + version: "7.1.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + slang: + dependency: transitive + description: + name: slang + sha256: "76dbf1f8e87ed1c417026e93a57512f6b6b597104ce72eed99b24a3ed08f0ba2" + url: "https://pub.dev" + source: hosted + version: "4.18.0" + slang_flutter: + dependency: transitive + description: + name: slang_flutter + sha256: "7523a9389b44210eabcfd58412d9131f284b9b3c22d731614f0016e9582ee996" + url: "https://pub.dev" + source: hosted + version: "4.18.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4227d54ceefd0bb8ca4c8fcb96e1719dc53f1ee1b6e2ca9d7a6069da160e4eae" + url: "https://pub.dev" + source: hosted + version: "1.3.12" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" + url: "https://pub.dev" + source: hosted + version: "2.4.2+1" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" + url: "https://pub.dev" + source: hosted + version: "2.4.2+3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "1581ffbf7a0e333b380d6a30737d78516b826cb35beb7fb0bf8a3ea0c678b465" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + url: "https://pub.dev" + source: hosted + version: "3.4.0+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" + theme_extensions_builder_annotation: + dependency: transitive + description: + name: theme_extensions_builder_annotation + sha256: "9249af881cd892aeac002bb016628becac51f12439df9af2a23e3c516b0470f5" + url: "https://pub.dev" + source: hosted + version: "7.4.0" + two_dimensional_scrollables: + dependency: transitive + description: + name: two_dimensional_scrollables + sha256: d8bd6471d5e40f6f6a2b76c70fad725f2f4b732582c3e4b5abd2d89c8ef34d31 + url: "https://pub.dev" + source: hosted + version: "0.5.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_image: + dependency: transitive + description: + name: universal_image + sha256: "2eae13df84a47960cc4148fec88f09031ea64cbf667b4131ff2c907dd7b7c6d1" + url: "https://pub.dev" + source: hosted + version: "1.0.12" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3" + url: "https://pub.dev" + source: hosted + version: "1.2.6" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "05c231fd7b23d2380f14c5cc10b7b93d60d4fa4a2fb4e0f032de27e44b5560e9" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..020cb15 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,57 @@ +name: guangya_flutter +description: 光鸭云盘客户端 - 支持文件管理、云下载、分享、TMDB刮削等功能 +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.9.1 + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + cupertino_icons: ^1.0.8 + flutter_riverpod: ^3.3.1 + riverpod_annotation: ^4.0.2 + dio: ^5.9.2 + crypto: ^3.0.6 + uuid: ^4.4.2 + intl: ^0.20.2 + path_provider: ^2.1.5 + path: ^1.9.1 + collection: ^1.19.1 + shadcn_ui: ^0.55.0 + lucide_icons_flutter: ^3.0.0 + url_launcher: ^6.3.2 + file_picker: ^12.0.0-beta.5 + install_plugin_v3: ^3.1.3 + pasteboard: ^0.5.0 + cached_network_image: ^3.4.1 + hive: ^2.2.3 + hive_flutter: ^1.1.0 + adaptive_platform_ui: ^0.1.106 + liquid_glass_easy: ^2.0.1 + window_manager: ^0.5.1 + logger: ^2.7.0 + easy_refresh: ^3.5.0 + package_info_plus: ^10.1.0 + device_info_plus: ^13.1.0 + share_plus: ^13.1.0 + permission_handler: ^12.0.1 + image_picker: ^1.2.2 + qr_flutter: ^4.1.0 + desktop_drop: ^0.7.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + build_runner: ^2.5.4 + json_serializable: ^6.13.0 + freezed: ^3.0.6 + riverpod_generator: ^4.0.3 + flutter_launcher_icons: ^0.14.4 + +flutter: + uses-material-design: true diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..e9dcb68 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('App starts', (WidgetTester tester) async { + // Basic smoke test + expect(true, isTrue); + }); +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..0390239 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(guangya_flutter LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "guangya_flutter") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..418dbce --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,35 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + DesktopDropPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopDropPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + PasteboardPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PasteboardPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..d1de7fe --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_drop + file_selector_windows + pasteboard + permission_handler_windows + screen_retriever_windows + share_plus + url_launcher_windows + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..2cc693f --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.ptools" "\0" + VALUE "FileDescription", "guangya_flutter" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "guangya_flutter" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.ptools. All rights reserved." "\0" + VALUE "OriginalFilename", "guangya_flutter.exe" "\0" + VALUE "ProductName", "guangya_flutter" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..4444f51 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"guangya_flutter", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_