diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..a0a7963 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/libraries/Dart_SDK.xml b/.idea/libraries/Dart_SDK.xml new file mode 100644 index 0000000..eef47cf --- /dev/null +++ b/.idea/libraries/Dart_SDK.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/libraries/KotlinJavaRuntime.xml b/.idea/libraries/KotlinJavaRuntime.xml new file mode 100644 index 0000000..6343b8a --- /dev/null +++ b/.idea/libraries/KotlinJavaRuntime.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f7a40a9 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.idea/runConfigurations/main_dart.xml b/.idea/runConfigurations/main_dart.xml new file mode 100644 index 0000000..7355adc --- /dev/null +++ b/.idea/runConfigurations/main_dart.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..47ea2c8 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# 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: linux + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: macos + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: web + 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/.symlink_test/link.txt b/.symlink_test/link.txt new file mode 120000 index 0000000..9dea829 --- /dev/null +++ b/.symlink_test/link.txt @@ -0,0 +1 @@ +C:/work/git/TtxPageAdmin/.symlink_test/target.txt \ No newline at end of file diff --git a/.symlink_test/target.txt b/.symlink_test/target.txt new file mode 100644 index 0000000..745e029 --- /dev/null +++ b/.symlink_test/target.txt @@ -0,0 +1 @@ +ok diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..43f569f --- /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..b07da9f --- /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..d259135 --- /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.example.ttx_page_admin" + 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.example.ttx_page_admin" + // 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..5080737 --- /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..c944121 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/ttx_page_admin/MainActivity.kt b/android/app/src/main/kotlin/com/example/ttx_page_admin/MainActivity.kt new file mode 100644 index 0000000..5f8d1b9 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/ttx_page_admin/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.ttx_page_admin + +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..9d5fd45 --- /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..923a587 --- /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..7fbf4db 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..56dac82 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..3e55090 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..cb1c98c 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..13b6e30 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..78803c6 --- /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..ef3776e --- /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..5080737 --- /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..3c13704 --- /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..fc2bb04 --- /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..6a03bc3 --- /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..a4439d1 --- /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/android/ttx_page_admin_android.iml b/android/ttx_page_admin_android.iml new file mode 100644 index 0000000..d568f28 --- /dev/null +++ b/android/ttx_page_admin_android.iml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_test.err b/flutter_test.err new file mode 100644 index 0000000..473a0f4 diff --git a/flutter_test.out b/flutter_test.out new file mode 100644 index 0000000..473a0f4 diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..aad54d6 --- /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..6c11bdb --- /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..edf55ba --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..edf55ba --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..08c5cf1 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,620 @@ +// !$*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)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.ttxPageAdmin; + 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.example.ttxPageAdmin.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.example.ttxPageAdmin.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.example.ttxPageAdmin.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.ttxPageAdmin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.ttxPageAdmin; + 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..ce8e420 --- /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..a488f57 --- /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..22ab93d --- /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..37a1e0a --- /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..e011932 --- /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..a488f57 --- /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..22ab93d --- /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..a58add3 --- /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..6a367ea --- /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..a621fc4 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..d992e02 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..c59a02a 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..5bfebe7 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..cff38ea 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..002beb1 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..62fbaeb 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..c59a02a 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..4a31415 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..fce56d9 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..fce56d9 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..82a4a79 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..59087df 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..4f47abf 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..29a3002 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..1b8d7b0 --- /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..1d106d6 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..1d106d6 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..1d106d6 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..9ea9635 --- /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..b84fe33 --- /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..1780cfc --- /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..2678ad0 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Ttx Page Admin + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ttx_page_admin + 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 + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..62acf3c --- /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..af33a9d --- /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..463fa9c --- /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/backend/studio_backend_client.dart b/lib/backend/studio_backend_client.dart new file mode 100644 index 0000000..6a33d91 --- /dev/null +++ b/lib/backend/studio_backend_client.dart @@ -0,0 +1,1045 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:pointycastle/export.dart'; + +part 'studio_backend_support.dart'; +part 'studio_backend_models.dart'; + +class StudioBackendClient { + // 登录与会话相关接口。 + StudioBackendClient({ + required String baseUrl, + String languageCode = 'zh', + Dio? dio, + }) : languageCode = languageCode, + _dio = + dio ?? + Dio( + BaseOptions( + baseUrl: _normalizeBaseUrl(baseUrl), + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 20), + sendTimeout: const Duration(seconds: 20), + responseType: ResponseType.json, + followRedirects: true, + validateStatus: (_) => true, + ), + ) { + _dio.options.baseUrl = _normalizeBaseUrl(baseUrl); + } + + final Dio _dio; + final String languageCode; + final Map _cookies = {}; + + String get baseUrl => _dio.options.baseUrl; + + Future login({ + required String workspace, + required String username, + required String password, + String authType = 'normal', + }) async { + final normalizedAuthType = authType.trim().toLowerCase(); + final usePasswordHash = + normalizedAuthType.isEmpty || normalizedAuthType == 'normal'; + final loginPayload = { + 'customer': workspace, + 'username': username, + 'authType': normalizedAuthType == 'normal' ? '' : authType, + 'encryption': usePasswordHash ? '1' : '0', + 'password': usePasswordHash + ? md5.convert(utf8.encode(password)).toString() + : password, + }; + final encryptedPayload = _encryptLoginPayload(loginPayload); + + final response = await _postJson( + 'rest/auth/login', + encryptedPayload, + session: StudioBackendSession.initial( + baseUrl: baseUrl, + workspace: workspace, + locale: languageCode, + ), + includeSessionCookies: true, + forceRawBody: true, + followRedirects: false, + ); + + final json = response.json; + final code = _extractCode(json); + final sessionMap = _extractMap( + json['session'] ?? json['context'] ?? json['data'] ?? {}, + ); + final context = _extractMap(json['context']); + + return StudioBackendLoginResult( + success: _isSuccess(code), + code: code, + message: _extractMessage(json), + leftDays: _extractInt(json['leftDays']), + session: sessionMap.isEmpty + ? null + : StudioBackendSession.fromJson( + baseUrl: baseUrl, + raw: sessionMap, + fallbackUser: username, + fallbackWorkspace: workspace, + fallbackLocale: languageCode, + ), + appConfig: _extractMap(json['appConfig'] ?? context['appConfig']), + systemConfig: _extractMap( + json['systemConfig'] ?? context['systemConfig'], + ), + raw: json, + ); + } + + Future checkLogin() async { + final response = await _getJson( + 'rest/auth/loginStatus', + includeSessionCookies: true, + ); + final json = response.json; + final code = _extractCode(json); + final context = _extractMap(json['context']); + final sessionMap = _extractMap( + json['session'] ?? context['session'] ?? context, + ); + + return StudioBackendLoginStatusResult( + success: _isSuccess(code), + code: code, + message: _extractMessage(json), + leftDays: _extractInt(json['leftDays']), + session: sessionMap.isEmpty + ? null + : StudioBackendSession.fromJson( + baseUrl: baseUrl, + raw: sessionMap, + fallbackUser: _extractString(sessionMap['user']), + fallbackWorkspace: _extractString(sessionMap['db']), + fallbackLocale: languageCode, + ), + appConfig: _extractMap(json['appConfig'] ?? context['appConfig']), + systemConfig: _extractMap( + json['systemConfig'] ?? context['systemConfig'], + ), + raw: json, + ); + } + + Future?> fetchLoginPageConfig() async { + final response = await _getJson('rest/auth/loginPageConfig'); + final json = response.json; + if (_isSuccess(_extractCode(json)) && json['data'] is Map) { + return _extractMap(json['data']); + } + if (json is Map) { + return json; + } + return null; + } + + Future fetchUserProfile( + StudioBackendSession session, + ) async { + final response = await _getJson('/user/getUser', session: session); + final json = response.json; + final rawUser = _extractMap(json['user'] ?? json['data'] ?? json); + if (rawUser.isEmpty) { + return null; + } + return StudioBackendUserProfile.fromJson(rawUser); + } + + Future fetchOrgByUser(StudioBackendSession session) async { + final response = await _postJson('/cbt/org/byUser', { + 'userCode': session.user, + }, session: session); + final json = response.json; + final rawOrg = _extractMap(json['data'] ?? json); + if (rawOrg.isEmpty) { + return null; + } + return StudioBackendOrg.fromJson(rawOrg); + } + + Future> fetchPermissionIds(StudioBackendSession session) async { + final response = await _postJson('studio/access/permissions', { + 'user': session.user, + }, session: session); + final json = response.json; + final candidates = []; + final map = Map.from(json); + for (final key in [ + 'menus', + 'navs', + 'data', + 'result', + 'items', + 'permissions', + 'nodes', + 'children', + ]) { + final value = map[key]; + if (value is List) { + candidates.addAll(value); + } else if (value is Map) { + candidates.add(value); + } + } + if (candidates.isEmpty && map['id'] != null) { + candidates.add(map); + } + + final ids = {}; + void walk(dynamic node) { + if (node is Map) { + final map = Map.from(node); + final id = _extractString(map['id']); + if (id.isNotEmpty) { + ids.add(id); + } + for (final key in ['children', 'navs', 'menus', 'items']) { + final value = map[key]; + if (value is List) { + for (final child in value) { + walk(child); + } + } + } + } else if (node is List) { + for (final child in node) { + walk(child); + } + } + } + + for (final item in candidates) { + walk(item); + } + return ids; + } + + Future>> fetchUpgradeLogs({ + StudioBackendSession? session, + }) async { + final response = await _getJson( + '/upgrade/log/loadAll', + session: session, + includeSessionCookies: true, + ); + return _extractListOfMaps(response.json); + } + + Future>> fetchSqlTemplateIndex({ + StudioBackendSession? session, + }) async { + final response = await _getJson( + 'rest/sqlTemplate/tidOidList', + session: session, + ); + return _extractListOfMaps(response.json); + } + + Future?> fetchSqlTemplate({ + required String tid, + required String oid, + StudioBackendSession? session, + }) async { + final response = await _getJson( + 'rest/sqlTemplate/$tid/$oid', + session: session, + ); + final json = response.json; + final data = _extractMap(json['data'] ?? json); + return data.isEmpty ? null : data; + } + + Future createSqlTemplate({ + required String tid, + required String oid, + required Map payload, + StudioBackendSession? session, + }) async { + return _postAction( + 'rest/sqlTemplate/$tid/$oid', + payload, + session: session, + ); + } + + Future updateSqlTemplate({ + required String tid, + required String oid, + required Map payload, + StudioBackendSession? session, + }) async { + return _postAction( + 'rest/sqlTemplate/update/$tid/$oid', + payload, + session: session, + ); + } + + Future>> fetchCreationNavigator({ + StudioBackendSession? session, + }) async { + final response = await _getJson( + 'rest/creation/navigator', + session: session, + ); + final json = response.json; + final structure = json['structure']; + if (structure is List) { + return structure + .whereType() + .map((item) => Map.from(item)) + .toList(); + } + return _extractListOfMaps(json['data'] ?? json); + } + + Future> fetchCreationNavigatorRecord({ + StudioBackendSession? session, + }) async { + final response = await _getJson( + 'rest/creation/navigator', + session: session, + ); + return _extractMap(response.json); + } + + Future saveCreationNavigator({ + required List> data, + int? version, + StudioBackendSession? session, + }) async { + return _postAction( + 'rest/creation/navigator', + { + 'data': data, + if (version != null) 'version': version, + }, + session: session, + ); + } + + Future>> fetchTableModels({ + StudioBackendSession? session, + }) async { + final response = await _getJson( + 'rest/creation/tableModels', + session: session, + ); + return _extractListOfMaps(response.json); + } + + Future?> fetchTableModel({ + required String code, + StudioBackendSession? session, + }) async { + final response = await _getJson( + 'rest/creation/tableModels/$code', + session: session, + ); + final data = _extractMap(response.json['data'] ?? response.json); + return data.isEmpty ? null : data; + } + + Future createTableModel({ + required Map payload, + StudioBackendSession? session, + }) async { + return _postAction( + 'rest/creation/tableModels', + payload, + session: session, + ); + } + + Future updateTableModel({ + required Map payload, + StudioBackendSession? session, + }) async { + return _postAction( + 'rest/creation/update/tableModels', + payload, + session: session, + ); + } + + Future deleteTableModel({ + required String code, + StudioBackendSession? session, + }) async { + return _deleteAction('rest/creation/tableModels/$code', session: session); + } + + Future>> fetchBillModels({ + StudioBackendSession? session, + }) async { + final response = await _getJson('rest/creation/billModels', session: session); + return _extractListOfMaps(response.json); + } + + Future?> fetchBillModel({ + required String code, + StudioBackendSession? session, + }) async { + final response = await _getJson( + 'rest/creation/billModels/$code', + session: session, + ); + final data = _extractMap(response.json['data'] ?? response.json); + return data.isEmpty ? null : data; + } + + Future createBillModel({ + required Map payload, + StudioBackendSession? session, + }) async { + return _postAction( + 'rest/creation/billModels', + payload, + session: session, + ); + } + + Future updateBillModel({ + required Map payload, + StudioBackendSession? session, + }) async { + return _postAction( + 'rest/creation/update/billModels', + payload, + session: session, + ); + } + + Future deleteBillModel({ + required String code, + StudioBackendSession? session, + }) async { + return _deleteAction('rest/creation/billModels/$code', session: session); + } + + Future>> fetchViewModelOptions({ + StudioBackendSession? session, + }) async { + final response = await _getJson( + 'rest/creation/filteringSelect/viewModel', + session: session, + ); + return _extractListOfMaps(response.json); + } + + Future?> fetchViewModel({ + required String tid, + required String oid, + StudioBackendSession? session, + }) async { + final response = await _getJson( + 'rest/creation/viewModels/$tid/$oid', + session: session, + ); + final list = _extractListOfMaps(response.json); + if (list.isNotEmpty) { + return list.first; + } + final data = _extractMap(response.json['data'] ?? response.json); + return data.isEmpty ? null : data; + } + + Future createViewModel({ + required Map payload, + StudioBackendSession? session, + }) async { + return _postAction( + 'rest/creation/viewModels', + payload, + session: session, + ); + } + + Future updateViewModel({ + required Map payload, + StudioBackendSession? session, + }) async { + return _postAction( + 'rest/creation/update/viewModels', + payload, + session: session, + ); + } + + Future deleteViewModel({ + required String tid, + required String oid, + StudioBackendSession? session, + }) async { + return _deleteAction('rest/creation/viewModels/$tid/$oid', session: session); + } + + Future deleteSqlTemplate({ + required String tid, + required String oid, + StudioBackendSession? session, + }) async { + return _deleteAction('rest/sqlTemplate/$tid/$oid', session: session); + } + + Future>> fetchPortalConfigs({ + StudioBackendSession? session, + }) async { + final response = await _getJson('rest/portal/loadAll', session: session); + return _extractListOfMaps(response.json); + } + + Future repairDepartment({ + StudioBackendSession? session, + }) async { + final response = await _getJson( + '/cbt/department/repair', + session: session, + ); + final raw = response.json; + final code = _extractCode(raw); + return StudioBackendActionResult( + success: _isSuccess(code), + code: code, + message: _extractMessage(raw), + data: _extractData(raw), + raw: raw, + ); + } + + Future downloadModelConfigs({ + StudioBackendSession? session, + }) async { + final normalizedPath = _normalizePath('rest/modelSync/downloadConfigs'); + final response = await _dio.get>( + normalizedPath, + options: Options( + headers: _headers( + requestPath: normalizedPath, + session: session, + includeSessionCookies: true, + ), + responseType: ResponseType.bytes, + followRedirects: true, + receiveTimeout: const Duration(seconds: 60), + ), + ); + _captureCookies(response); + final bytes = response.data ?? []; + final filename = _filenameFromContentDisposition( + response.headers.value('content-disposition') ?? + response.headers.value('Content-Disposition') ?? + '', + fallback: 'download-configs.zip', + ); + return StudioBackendDownloadResult( + bytes: Uint8List.fromList(bytes), + filename: filename, + contentType: response.headers.value('content-type') ?? '', + statusCode: response.statusCode ?? 0, + rawHeaders: response.headers.map, + ); + } + + Future runSql({ + required String sql, + int limit = 100, + List params = const [], + StudioBackendSession? session, + }) async { + return _postAction( + 'cbt/sqlrunner/run', + { + 'sql': sql, + 'limit': limit, + 'params': params, + }, + session: session, + ); + } + + Future logout({StudioBackendSession? session}) async { + await _getJson( + '/rest/auth/logout', + session: session, + includeSessionCookies: true, + ); + _cookies.clear(); + } + + Future<_BackendJsonResponse> _getJson( + String path, { + StudioBackendSession? session, + bool includeSessionCookies = false, + }) async { + final normalizedPath = _normalizePath(path); + final response = await _dio.get( + normalizedPath, + options: Options( + headers: _headers( + requestPath: normalizedPath, + session: session, + includeSessionCookies: includeSessionCookies, + ), + ), + ); + _captureCookies(response); + return _BackendJsonResponse.fromResponse(response); + } + + Future<_BackendJsonResponse> _postJson( + String path, + Object data, { + StudioBackendSession? session, + bool includeSessionCookies = false, + bool forceRawBody = false, + bool followRedirects = true, + }) async { + final normalizedPath = _normalizePath(path); + final response = await _dio.post( + normalizedPath, + data: data, + options: Options( + headers: _headers( + requestPath: normalizedPath, + session: session, + includeSessionCookies: includeSessionCookies, + json: true, + ), + requestEncoder: forceRawBody && data is String + ? (request, options) => utf8.encode(request) + : null, + followRedirects: followRedirects, + ), + ); + _captureCookies(response); + return _BackendJsonResponse.fromResponse(response); + } + + Future _postAction( + String path, + Map data, { + StudioBackendSession? session, + bool includeSessionCookies = false, + bool json = true, + }) async { + final response = await _postJson( + path, + data, + session: session, + includeSessionCookies: includeSessionCookies, + followRedirects: true, + ); + final raw = response.json; + final code = _extractCode(raw); + return StudioBackendActionResult( + success: _isSuccess(code), + code: code, + message: _extractMessage(raw), + data: _extractData(raw), + raw: raw, + ); + } + + Future _deleteAction( + String path, { + StudioBackendSession? session, + }) async { + final normalizedPath = _normalizePath(path); + final response = await _dio.delete( + normalizedPath, + options: Options( + headers: _headers( + requestPath: normalizedPath, + session: session, + includeSessionCookies: true, + ), + followRedirects: true, + ), + ); + _captureCookies(response); + final rawResponse = _BackendJsonResponse.fromResponse(response).json; + final code = _extractCode(rawResponse); + return StudioBackendActionResult( + success: _isSuccess(code), + code: code, + message: _extractMessage(rawResponse), + data: _extractData(rawResponse), + raw: rawResponse, + ); + } + + Map _headers({ + required String requestPath, + StudioBackendSession? session, + bool includeSessionCookies = false, + bool json = false, + }) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final headers = { + 'X-Client-Language': languageCode, + 'x-locale': languageCode, + 'x-Language': languageCode, + 'x-Timezone': _localTimezoneCode(), + 'X-cbtVersion': '2.9.x', + 'X-device': 'browser', + 'X-t': '$timestamp', + 'X-v': _buildFab(requestPath, timestamp), + 'Accept': 'application/json', + }; + + if (json) { + headers['Content-Type'] = 'application/json'; + } + + if (session != null) { + headers.addAll(session.toHeaders()); + } + + final cookieHeader = _cookieHeader( + includeSessionCookies: includeSessionCookies, + ); + if (!kIsWeb && cookieHeader.isNotEmpty) { + headers['Cookie'] = cookieHeader; + } + + return headers; + } + + String _cookieHeader({required bool includeSessionCookies}) { + if (_cookies.isEmpty) { + return ''; + } + if (!includeSessionCookies && _cookies.isEmpty) { + return ''; + } + return _cookies.entries + .map((entry) => '${entry.key}=${entry.value}') + .join('; '); + } + + void _captureCookies(Response response) { + final values = response.headers.map['set-cookie']; + if (values == null || values.isEmpty) { + return; + } + + for (final headerValue in values) { + final parts = headerValue.split(';'); + if (parts.isEmpty) { + continue; + } + final pair = parts.first.trim(); + final index = pair.indexOf('='); + if (index <= 0) { + continue; + } + final name = pair.substring(0, index).trim(); + final value = pair.substring(index + 1).trim(); + if (name.isNotEmpty) { + _cookies[name] = value; + } + } + } + + String _normalizePath(String path) { + if (path.startsWith('/')) { + return path.substring(1); + } + return path; + } + + static String _normalizeBaseUrl(String baseUrl) { + final normalized = baseUrl.trim().isEmpty + ? 'http://localhost:9001/' + : baseUrl.trim(); + return normalized.endsWith('/') ? normalized : '$normalized/'; + } + + static bool _isSuccess(String code) { + return code == '0' || code == '200' || code == 'true'; + } + + static String _extractCode(Map json) { + for (final key in ['code', 'status', 'errorCode']) { + final value = json[key]; + if (value != null && value.toString().isNotEmpty) { + return value.toString(); + } + } + final httpStatus = json['_httpStatus']; + if (httpStatus != null && httpStatus.toString() != '200') { + return 'HTTP $httpStatus'; + } + return ''; + } + + static String _extractMessage(Map json) { + final redirectMessage = _extractRedirectMessage(json); + if (redirectMessage.isNotEmpty) { + return redirectMessage; + } + for (final key in ['msg', 'message', 'errorMsg', 'error', 'raw']) { + final value = json[key]; + if (value != null && value.toString().isNotEmpty) { + return _truncateMessage(value.toString()); + } + } + final httpStatus = json['_httpStatus']; + if (httpStatus != null && httpStatus.toString() != '200') { + return 'HTTP $httpStatus'; + } + return ''; + } + + static String _extractRedirectMessage(Map json) { + final httpStatus = int.tryParse(json['_httpStatus']?.toString() ?? ''); + if (httpStatus == null || + !const {301, 302, 303, 307, 308}.contains(httpStatus)) { + return ''; + } + + final location = json['_location']?.toString() ?? ''; + if (location.isEmpty) { + return 'HTTP $httpStatus,后端返回重定向,但没有 Location'; + } + + final uri = Uri.tryParse(location); + final path = uri?.path ?? location; + final db = uri?.queryParameters['db'] ?? ''; + + if (path == '/api/customerError') { + final suffix = db.isEmpty ? '' : ':$db'; + return '工作区无效或后端未配置该客户$suffix'; + } + if (path == '/init/admin') { + return '工作区尚未完成初始化:$location'; + } + if (path == '/rest/redirect/noLicense') { + return '后端未安装 License'; + } + if (path == '/rest/redirect/expire') { + return '后端 License 已过期'; + } + if (path == '/rest/redirect/onlineExceed' || + path == '/rest/redirect/onlineOver') { + return '后端在线人数超过授权限制'; + } + if (path == '/rest/redirect/403') { + return '后端拒绝访问,可能是权限或 IP 风控拦截'; + } + if (path == '/rest/redirect/timeout') { + return '登录会话已超时,请重试'; + } + + return 'HTTP $httpStatus,后端重定向到:$location'; + } + + static String _truncateMessage(String message) { + const maxLength = 600; + if (message.length <= maxLength) { + return message; + } + return '${message.substring(0, maxLength)}...'; + } + + static Map _extractMap(dynamic value) { + if (value is Map) { + return Map.from(value); + } + return {}; + } + + static List> _extractListOfMaps(dynamic value) { + if (value is List) { + return value + .whereType() + .map((item) => Map.from(item)) + .toList(); + } + if (value is Map) { + final map = Map.from(value); + for (final key in ['data', 'list', 'items', 'rows', 'result']) { + final candidate = map[key]; + if (candidate is List) { + return _extractListOfMaps(candidate); + } + } + } + return >[]; + } + + static dynamic _extractData(Map json) { + for (final key in ['data', 'result', 'items', 'list']) { + final value = json[key]; + if (value != null) { + return value; + } + } + return json; + } + + static String _filenameFromContentDisposition( + String contentDisposition, { + required String fallback, + }) { + if (contentDisposition.isEmpty) { + return fallback; + } + final match = RegExp( + r'''filename\*?=(?:UTF-8'')?"?([^";]+)"?''', + ).firstMatch(contentDisposition); + if (match != null) { + return Uri.decodeFull(match.group(1) ?? fallback); + } + return fallback; + } + + static String _extractString(dynamic value) { + if (value == null) { + return ''; + } + return value.toString(); + } + + static int? _extractInt(dynamic value) { + if (value == null) { + return null; + } + if (value is int) { + return value; + } + return int.tryParse(value.toString()); + } + + String _encryptLoginPayload(Map payload) { + final plainText = jsonEncode(payload); + final seed = _keyGen(); + final keyHex = _sha1PrngSecureRandoms(seed); + final keyBytes = _hexToUnsignedBytes(keyHex); + final cipher = PaddedBlockCipher('AES/ECB/PKCS7') + ..init( + true, + PaddedBlockCipherParameters( + KeyParameter(Uint8List.fromList(keyBytes)), + null, + ), + ); + final encryptedBytes = cipher.process( + Uint8List.fromList(utf8.encode(plainText)), + ); + return base64Encode(encryptedBytes); + } + + String _keyGen() { + final seed = DateTime.now().millisecondsSinceEpoch ~/ 60000; + final head = sha1 + .convert(utf8.encode(seed.toRadixString(16))) + .toString() + .substring(0, 12) + .toUpperCase(); + final middle = md5 + .convert(utf8.encode(seed.toRadixString(24))) + .toString() + .substring(0, 6) + .toLowerCase(); + final tail = sha256 + .convert(utf8.encode(seed.toRadixString(36))) + .toString() + .substring(0, 14) + .toUpperCase(); + return '$head$middle$tail'; + } + + String _sha1PrngSecureRandoms(String seed, {int digestHexSize = 64}) { + var state = sha1.convert(utf8.encode(seed)).toString(); + var output = sha1.convert(_hexToUnsignedBytes(state)).toString(); + var key = output; + while (key.length < digestHexSize) { + state = _updateHexState(state, output); + output = sha1.convert(_hexToUnsignedBytes(state)).toString(); + key += output; + } + return key.substring(0, digestHexSize); + } + + String _updateHexState(String stateHex, String outputHex) { + final state = _hexToSignedBytes(stateHex); + final output = _hexToSignedBytes(outputHex); + var last = 1; + var changed = false; + for (var index = 0; index < state.length; index++) { + final raw = state[index] + output[index] + last; + changed = changed || state[index] != raw; + var normalized = raw; + if (normalized > 127) { + normalized -= 256; + } else if (normalized < -128) { + normalized += 256; + } + state[index] = normalized; + last = raw >> 8; + } + if (!changed && state.isNotEmpty) { + state[0] = state[0] + 1; + } + return _signedBytesToHex(state); + } + + Uint8List _hexToUnsignedBytes(String hexText) { + if (hexText.length.isOdd) { + throw const FormatException('Invalid hex length'); + } + final bytes = Uint8List(hexText.length ~/ 2); + for (var index = 0; index < bytes.length; index++) { + final pair = hexText.substring(index * 2, index * 2 + 2); + bytes[index] = int.parse(pair, radix: 16); + } + return bytes; + } + + List _hexToSignedBytes(String hexText) { + final unsigned = _hexToUnsignedBytes(hexText); + return unsigned.map((value) => value > 127 ? value - 256 : value).toList(); + } + + String _signedBytesToHex(List signedBytes) { + final buffer = StringBuffer(); + for (final value in signedBytes) { + final unsigned = value < 0 ? value + 256 : value; + buffer.write(unsigned.toRadixString(16).padLeft(2, '0')); + } + return buffer.toString(); + } + + String _buildFab(String requestPath, int timestamp, {String button = ''}) { + String uriPath = requestPath; + if (uriPath.toLowerCase().startsWith('http://') || + uriPath.toLowerCase().startsWith('https://')) { + uriPath = Uri.parse(uriPath).path; + } else { + final fixed = uriPath.startsWith('/') ? uriPath : '/$uriPath'; + uriPath = Uri.parse('http://x$fixed').path; + } + final encodedUri = Uri.encodeComponent(uriPath); + final seed = + 'cyber$timestamp$encodedUri$timestamp$encodedUri${button}trans'; + final base64Seed = base64Encode(utf8.encode(seed)); + final digest = sha256.convert(utf8.encode(base64Seed)); + return Uri.encodeComponent(base64Encode(digest.bytes)); + } +} + diff --git a/lib/backend/studio_backend_models.dart b/lib/backend/studio_backend_models.dart new file mode 100644 index 0000000..b0f7cd2 --- /dev/null +++ b/lib/backend/studio_backend_models.dart @@ -0,0 +1,335 @@ +part of 'studio_backend_client.dart'; + +class StudioBackendLoginResult { + const StudioBackendLoginResult({ + required this.success, + required this.code, + required this.message, + required this.raw, + this.leftDays, + this.session, + this.appConfig, + this.systemConfig, + }); + + final bool success; + final String code; + final String message; + final int? leftDays; + final StudioBackendSession? session; + final Map? appConfig; + final Map? systemConfig; + final Map raw; +} + +class StudioBackendLoginStatusResult { + const StudioBackendLoginStatusResult({ + required this.success, + required this.code, + required this.message, + required this.raw, + this.leftDays, + this.session, + this.appConfig, + this.systemConfig, + }); + + final bool success; + final String code; + final String message; + final int? leftDays; + final StudioBackendSession? session; + final Map? appConfig; + final Map? systemConfig; + final Map raw; +} + +class StudioBackendSession { + const StudioBackendSession({ + required this.baseUrl, + required this.user, + required this.db, + required this.token, + required this.client, + required this.locale, + required this.timezone, + required this.portal, + required this.secretGen, + required this.sso, + required this.ssoToken, + required this.ssoApp, + required this.ssoClient, + required this.raw, + }); + + factory StudioBackendSession.initial({ + required String baseUrl, + required String workspace, + required String locale, + }) { + return StudioBackendSession( + baseUrl: baseUrl, + user: 'default_user', + db: workspace, + token: 'default_token', + client: '', + locale: locale, + timezone: _localTimezoneCode(), + portal: '', + secretGen: '', + sso: '', + ssoToken: '', + ssoApp: '', + ssoClient: '', + raw: const {}, + ); + } + + factory StudioBackendSession.fromJson({ + required String baseUrl, + required Map raw, + required String fallbackUser, + required String fallbackWorkspace, + required String fallbackLocale, + }) { + return StudioBackendSession( + baseUrl: baseUrl, + user: _pick(raw, ['user', 'username'], fallbackUser), + db: _pick(raw, ['db', 'customer'], fallbackWorkspace), + token: _pick(raw, ['tk', 'token'], ''), + client: _pick(raw, ['client'], ''), + locale: _pick(raw, ['locale', 'language'], fallbackLocale), + timezone: _normalizeTimezone(_pick(raw, ['timezone'], '')), + portal: _pick(raw, ['portal'], ''), + secretGen: _pick(raw, ['secretGen'], ''), + sso: _pick(raw, ['sso'], ''), + ssoToken: _pick(raw, ['ssoToken'], ''), + ssoApp: _pick(raw, ['ssoApp'], ''), + ssoClient: _pick(raw, ['ssoClient'], ''), + raw: raw, + ); + } + + final String baseUrl; + final String user; + final String db; + final String token; + final String client; + final String locale; + final String timezone; + final String portal; + final String secretGen; + final String sso; + final String ssoToken; + final String ssoApp; + final String ssoClient; + final Map raw; + + Map toHeaders() { + final headers = {}; + void put(String key, String value) { + if (value.isNotEmpty) { + headers[key] = value; + } + } + + put('X-user', user); + put('X-Token', token); + put('X-DB', db); + put('x-client', client); + put('x-locale', locale); + put('X-Portal', portal); + put('X-SSO', sso); + put('X-SSO-TOKEN', ssoToken); + put('X-SSO-APP', ssoApp); + put('X-SSO-CLIENT', ssoClient); + put('x-Timezone', _normalizeTimezone(timezone)); + put('x-params', '{}'); + if (secretGen.isNotEmpty) { + put('x-sec', secretGen); + } + return headers; + } + + StudioBackendSession copyWith({ + String? user, + String? db, + String? token, + String? client, + String? locale, + String? timezone, + String? portal, + String? secretGen, + String? sso, + String? ssoToken, + String? ssoApp, + String? ssoClient, + Map? raw, + }) { + return StudioBackendSession( + baseUrl: baseUrl, + user: user ?? this.user, + db: db ?? this.db, + token: token ?? this.token, + client: client ?? this.client, + locale: locale ?? this.locale, + timezone: timezone ?? this.timezone, + portal: portal ?? this.portal, + secretGen: secretGen ?? this.secretGen, + sso: sso ?? this.sso, + ssoToken: ssoToken ?? this.ssoToken, + ssoApp: ssoApp ?? this.ssoApp, + ssoClient: ssoClient ?? this.ssoClient, + raw: raw ?? this.raw, + ); + } +} + +class StudioBackendUserProfile { + const StudioBackendUserProfile({ + required this.code, + required this.name, + required this.isDeveloper, + required this.portal, + required this.portraitUrl, + required this.raw, + }); + + factory StudioBackendUserProfile.fromJson(Map raw) { + return StudioBackendUserProfile( + code: _pick(raw, ['code', 'userCode', 'id'], ''), + name: _pick(raw, ['name', 'userName'], ''), + isDeveloper: _pick(raw, ['isDeveloper'], 'false').toLowerCase() == 'true', + portal: _pick(raw, ['portal'], ''), + portraitUrl: _pick(raw, ['portraitUrl'], ''), + raw: raw, + ); + } + + final String code; + final String name; + final bool isDeveloper; + final String portal; + final String portraitUrl; + final Map raw; +} + +class StudioBackendOrg { + const StudioBackendOrg({ + required this.code, + required this.name, + required this.raw, + }); + + factory StudioBackendOrg.fromJson(Map raw) { + return StudioBackendOrg( + code: _pick(raw, ['code', 'orgCode'], ''), + name: _pick(raw, ['name', 'orgName'], ''), + raw: raw, + ); + } + + final String code; + final String name; + final Map raw; +} + +class StudioBackendActionResult { + const StudioBackendActionResult({ + required this.success, + required this.code, + required this.message, + required this.data, + required this.raw, + }); + + final bool success; + final String code; + final String message; + final dynamic data; + final Map raw; +} + +class StudioBackendDownloadResult { + const StudioBackendDownloadResult({ + required this.bytes, + required this.filename, + required this.contentType, + required this.statusCode, + required this.rawHeaders, + }); + + final Uint8List bytes; + final String filename; + final String contentType; + final int statusCode; + final Map> rawHeaders; +} + +class _BackendJsonResponse { + const _BackendJsonResponse({required this.json, required this.statusCode}); + + factory _BackendJsonResponse.fromResponse(Response response) { + final data = response.data; + final statusCode = response.statusCode ?? 0; + final location = response.headers.value('location') ?? ''; + if (data is String) { + try { + final decoded = jsonDecode(data); + if (decoded is Map) { + return _BackendJsonResponse( + json: _withHttpMeta(decoded, statusCode, location), + statusCode: statusCode, + ); + } + return _BackendJsonResponse( + json: _withHttpMeta( + {'data': decoded}, + statusCode, + location, + ), + statusCode: statusCode, + ); + } catch (_) { + return _BackendJsonResponse( + json: _withHttpMeta( + {'raw': data}, + statusCode, + location, + ), + statusCode: statusCode, + ); + } + } + if (data is Map) { + return _BackendJsonResponse( + json: _withHttpMeta(data, statusCode, location), + statusCode: statusCode, + ); + } + return _BackendJsonResponse( + json: _withHttpMeta( + {'data': data}, + statusCode, + location, + ), + statusCode: statusCode, + ); + } + + static Map _withHttpMeta( + Map json, + int statusCode, + String location, + ) { + return { + ...json, + '_httpStatus': statusCode, + if (location.isNotEmpty) '_location': location, + }; + } + + final Map json; + final int statusCode; +} + diff --git a/lib/backend/studio_backend_support.dart b/lib/backend/studio_backend_support.dart new file mode 100644 index 0000000..55dbd40 --- /dev/null +++ b/lib/backend/studio_backend_support.dart @@ -0,0 +1,63 @@ +part of 'studio_backend_client.dart'; + +String _localTimezoneCode() { + final now = DateTime.now(); + final timezoneName = now.timeZoneName.trim(); + if (timezoneName.isNotEmpty) { + final normalizedTimezone = _normalizeTimezone(timezoneName); + if (normalizedTimezone != timezoneName) { + return normalizedTimezone; + } + } + + final offset = now.timeZoneOffset; + if (offset.inHours == 8 && offset.inMinutes.remainder(60) == 0) { + return 'Asia/Shanghai'; + } + + final totalMinutes = offset.inMinutes; + final sign = totalMinutes < 0 ? '-' : '+'; + final absoluteMinutes = totalMinutes.abs(); + final hours = (absoluteMinutes ~/ 60).toString().padLeft(2, '0'); + final minutes = (absoluteMinutes % 60).toString().padLeft(2, '0'); + return '$sign$hours:$minutes'; +} + +String _normalizeTimezone(String value) { + final timezone = value.trim(); + if (timezone.isEmpty) { + return _localTimezoneCode(); + } + + const windowsTimezoneMap = { + '中国标准时间': 'Asia/Shanghai', + 'China Standard Time': 'Asia/Shanghai', + 'Taipei Standard Time': 'Asia/Taipei', + 'Tokyo Standard Time': 'Asia/Tokyo', + 'Korea Standard Time': 'Asia/Seoul', + 'Singapore Standard Time': 'Asia/Singapore', + 'UTC': 'UTC', + }; + + final mappedTimezone = windowsTimezoneMap[timezone]; + if (mappedTimezone != null) { + return mappedTimezone; + } + + final asciiTimezone = RegExp(r'^[\x20-\x7E]+$').hasMatch(timezone); + if (asciiTimezone) { + return timezone; + } + + return 'Asia/Shanghai'; +} + +String _pick(Map map, List keys, String fallback) { + for (final key in keys) { + final value = map[key]; + if (value != null && value.toString().isNotEmpty) { + return value.toString(); + } + } + return fallback; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..5e233d1 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,7 @@ +export 'studio_app.dart'; + +import 'studio_app.dart' as studio; + +void main() { + studio.runStudioApp(); +} diff --git a/lib/platform/studio_download_saver.dart b/lib/platform/studio_download_saver.dart new file mode 100644 index 0000000..893b2c1 --- /dev/null +++ b/lib/platform/studio_download_saver.dart @@ -0,0 +1,2 @@ +export 'studio_download_saver_stub.dart' + if (dart.library.io) 'studio_download_saver_io.dart'; diff --git a/lib/platform/studio_download_saver_io.dart b/lib/platform/studio_download_saver_io.dart new file mode 100644 index 0000000..85adb67 --- /dev/null +++ b/lib/platform/studio_download_saver_io.dart @@ -0,0 +1,24 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_selector/file_selector.dart'; + +// 桌面端保存下载文件:打开系统“另存为”对话框并落盘。 +Future saveBytesToFile({ + required Uint8List bytes, + required String suggestedName, +}) async { + final location = await getSaveLocation( + suggestedName: suggestedName, + acceptedTypeGroups: [ + XTypeGroup(label: 'Zip', extensions: ['zip']), + ], + ); + final path = location?.path; + if (path == null || path.trim().isEmpty) { + return null; + } + final file = File(path); + await file.writeAsBytes(bytes, flush: true); + return path; +} diff --git a/lib/platform/studio_download_saver_stub.dart b/lib/platform/studio_download_saver_stub.dart new file mode 100644 index 0000000..cd60024 --- /dev/null +++ b/lib/platform/studio_download_saver_stub.dart @@ -0,0 +1,8 @@ +import 'dart:typed_data'; + +Future saveBytesToFile({ + required Uint8List bytes, + required String suggestedName, +}) async { + return null; +} diff --git a/lib/studio_app.dart b/lib/studio_app.dart new file mode 100644 index 0000000..cb2c0c6 --- /dev/null +++ b/lib/studio_app.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'backend/studio_backend_client.dart'; +import 'platform/studio_download_saver.dart'; + +part 'studio_app_theme.dart'; +part 'studio_app_models.dart'; +part 'studio_app_entry.dart'; +part 'studio_app_login.dart'; +part 'studio_app_shell.dart'; +part 'studio_app_shell_sidebar.dart'; +part 'studio_app_shell_workspace.dart'; +part 'studio_app_pages.dart'; +part 'studio_app_pages_history.dart'; +part 'studio_app_pages_operations.dart'; +part 'studio_app_pages_sql.dart'; +part 'studio_app_pages_form_editor.dart'; +part 'studio_app_pages_portal.dart'; + +void runStudioApp() { + runApp(const StudioApp()); +} diff --git a/lib/studio_app_entry.dart b/lib/studio_app_entry.dart new file mode 100644 index 0000000..ec6231d --- /dev/null +++ b/lib/studio_app_entry.dart @@ -0,0 +1,40 @@ +part of 'studio_app.dart'; + +class StudioApp extends StatefulWidget { + const StudioApp({super.key}); + + @override + State createState() => _StudioAppState(); +} + +class _StudioAppState extends State { + ThemeMode _themeMode = ThemeMode.light; + + void _toggleTheme() { + setState(() { + _themeMode = _themeMode == ThemeMode.dark + ? ThemeMode.light + : ThemeMode.dark; + }); + } + + @override + Widget build(BuildContext context) { + final languageCode = Uri.base.queryParameters['lang'] ?? 'zh'; + + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'TTX Studio', + theme: buildStudioTheme(Brightness.light), + darkTheme: buildStudioTheme(Brightness.dark), + themeMode: _themeMode, + home: SelectionArea( + child: StudioBootstrap( + languageCode: languageCode, + themeMode: _themeMode, + onToggleTheme: _toggleTheme, + ), + ), + ); + } +} diff --git a/lib/studio_app_login.dart b/lib/studio_app_login.dart new file mode 100644 index 0000000..01aad1a --- /dev/null +++ b/lib/studio_app_login.dart @@ -0,0 +1,551 @@ +part of 'studio_app.dart'; + +class StudioBootstrap extends StatefulWidget { + const StudioBootstrap({ + super.key, + required this.languageCode, + required this.themeMode, + required this.onToggleTheme, + }); + + final String languageCode; + final ThemeMode themeMode; + final VoidCallback onToggleTheme; + + @override + State createState() => _StudioBootstrapState(); +} + +class _StudioBootstrapState extends State { + StudioRuntime? _runtime; + + void _handleLogin(StudioRuntime runtime) { + setState(() { + _runtime = runtime; + }); + } + + void _handleLogout() { + final runtime = _runtime; + if (runtime == null) { + return; + } + + runtime.client + .logout(session: runtime.session) + .catchError((_) {}) + .whenComplete(() { + if (!mounted) { + return; + } + setState(() { + _runtime = null; + }); + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + child: _runtime == null + ? StudioLoginView( + key: const ValueKey('studio-login'), + languageCode: widget.languageCode, + themeMode: widget.themeMode, + onToggleTheme: widget.onToggleTheme, + onLogin: _handleLogin, + ) + : StudioShell( + key: const ValueKey('studio-shell'), + runtime: _runtime!, + languageCode: widget.languageCode, + themeMode: widget.themeMode, + onToggleTheme: widget.onToggleTheme, + onLogout: _handleLogout, + ), + ); + } +} + +class StudioLoginView extends StatefulWidget { + const StudioLoginView({ + super.key, + required this.languageCode, + required this.themeMode, + required this.onToggleTheme, + required this.onLogin, + }); + + final String languageCode; + final ThemeMode themeMode; + final VoidCallback onToggleTheme; + final ValueChanged onLogin; + + @override + State createState() => _StudioLoginViewState(); +} + +class _StudioLoginViewState extends State { + late final TextEditingController _backendUrlController; + late final TextEditingController _workspaceController; + late final TextEditingController _usernameController; + late final TextEditingController _passwordController; + bool _loading = false; + String? _errorMessage; + Map _loginConfig = {}; + + @override + void initState() { + super.initState(); + _backendUrlController = TextEditingController( + text: 'http://localhost:9001/', + ); + _workspaceController = TextEditingController(text: _defaultWorkspace); + _usernameController = TextEditingController(); + _passwordController = TextEditingController(); + _loadLoginConfig(); + } + + @override + void dispose() { + _backendUrlController.dispose(); + _workspaceController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _loadLoginConfig() async { + try { + final client = StudioBackendClient( + baseUrl: _backendUrlController.text, + languageCode: widget.languageCode, + ); + final config = await client.fetchLoginPageConfig(); + if (!mounted) { + return; + } + setState(() { + _loginConfig = config ?? {}; + }); + _applyLoginConfig(_loginConfig); + } catch (error, stackTrace) { + _printFullError('加载登录配置失败', error, stackTrace); + if (!mounted) { + return; + } + setState(() { + _errorMessage = '后端配置暂时不可用,仍可继续尝试登录'; + }); + } + } + + Future _submit() async { + final workspace = _normalizeWorkspace(_workspaceController.text); + final username = _usernameController.text.trim(); + final password = _passwordController.text; + final backendUrl = _backendUrlController.text.trim(); + + if (workspace.isEmpty || username.isEmpty || password.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('请先填写工作区、用户名和密码'))); + return; + } + + if (_workspaceController.text.trim() != workspace) { + _workspaceController.text = workspace; + } + + if (_loading) { + return; + } + + setState(() { + _loading = true; + _errorMessage = null; + }); + + try { + final client = StudioBackendClient( + baseUrl: backendUrl, + languageCode: widget.languageCode, + ); + final loginResult = await client.login( + workspace: workspace, + username: username, + password: password, + authType: '', + ); + if (!loginResult.success || loginResult.session == null) { + final fallbackMessage = loginResult.success + ? '登录成功但未返回会话信息' + : '后端登录失败,code=${loginResult.code.isEmpty ? '空' : loginResult.code}'; + throw _LoginFailureException( + loginResult.message.isNotEmpty + ? loginResult.message + : fallbackMessage, + code: loginResult.code, + raw: loginResult.raw, + ); + } + + final session = loginResult.session!; + final profile = await client.fetchUserProfile(session); + final org = await client.fetchOrgByUser(session); + final menuIds = await client.fetchPermissionIds(session); + final appName = + _extractAppName(loginResult.appConfig) ?? + _extractAppName(loginResult.systemConfig) ?? + _extractAppName(_loginConfig); + final organizationName = org?.name ?? ''; + final backendAppName = appName ?? 'TTX Studio'; + + if (!mounted) { + return; + } + + widget.onLogin( + StudioRuntime( + backendUrl: backendUrl, + client: client, + session: session, + user: StudioUser( + workspace: session.db.isNotEmpty ? session.db : workspace, + displayName: (profile?.name.isNotEmpty ?? false) + ? profile!.name + : username, + role: profile?.isDeveloper == true ? '开发者' : '管理员', + languageCode: widget.languageCode, + backendAppName: backendAppName, + organizationName: organizationName, + ), + loginConfig: loginResult.appConfig ?? _loginConfig, + systemConfig: loginResult.systemConfig ?? const {}, + orgName: organizationName, + allowedMenuIds: menuIds, + profile: profile, + ), + ); + } catch (error, stackTrace) { + _printFullError('登录失败', error, stackTrace); + if (!mounted) { + return; + } + setState(() { + _errorMessage = _normalizeErrorMessage(error); + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_errorMessage ?? '登录失败'))); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + void _applyLoginConfig(Map config) { + final backendUrl = _extractStringFromConfig(config, const [ + 'server', + 'url', + 'loginPageServer', + ]); + final workspace = _extractStringFromConfig(config, const [ + 'db', + 'customer', + 'workspace', + ]); + + if (backendUrl.isNotEmpty && + _backendUrlController.text.trim() == 'http://localhost:9001/') { + _backendUrlController.text = backendUrl; + } + + if (workspace.isNotEmpty && + _workspaceController.text.trim() == _defaultWorkspace) { + _workspaceController.text = _normalizeWorkspace(workspace); + } + } + + String _normalizeWorkspace(String value) { + final workspace = value.trim(); + if (workspace == 'ttx-xwms-tes') { + return _defaultWorkspace; + } + return workspace; + } + + String _normalizeErrorMessage(Object error) { + if (error is _LoginFailureException) { + return error.message; + } + final text = error.toString(); + const marker = 'Bad state: '; + if (text.startsWith(marker)) { + return text.substring(marker.length); + } + return text; + } + + void _printFullError(String title, Object error, StackTrace stackTrace) { + final buffer = StringBuffer() + ..writeln('========== $title ==========') + ..writeln('error: $error') + ..writeln('stackTrace:') + ..writeln(stackTrace); + + if (error is _LoginFailureException) { + buffer + ..writeln('code: ${error.code.isEmpty ? '空' : error.code}') + ..writeln('raw response:') + ..writeln(const JsonEncoder.withIndent(' ').convert(error.raw)); + } + + buffer.writeln('========== $title end ==========='); + _debugPrintLong(buffer.toString()); + } + + void _debugPrintLong(String text) { + const chunkSize = 900; + for (var start = 0; start < text.length; start += chunkSize) { + final end = (start + chunkSize).clamp(0, text.length); + debugPrint(text.substring(start, end)); + } + } + + @override + Widget build(BuildContext context) { + final backendHint = _backendUrlController.text.trim().isEmpty + ? 'http://localhost:9001/' + : _backendUrlController.text.trim(); + + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: _loginGradientColors(context), + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Stack( + children: [ + Positioned( + right: 20, + top: 20, + child: SafeArea( + child: _ThemeToggleButton( + themeMode: widget.themeMode, + onPressed: widget.onToggleTheme, + ), + ), + ), + Positioned( + left: -120, + top: -120, + child: _GlowCircle( + color: const Color(0xFF1AA6A6).withOpacity(0.20), + ), + ), + Positioned( + right: -100, + bottom: -120, + child: _GlowCircle( + color: const Color(0xFFFFA94D).withOpacity(0.16), + ), + ), + SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(24), + child: _LoginForm( + backendUrlController: _backendUrlController, + workspaceController: _workspaceController, + usernameController: _usernameController, + passwordController: _passwordController, + loading: _loading, + backendHint: backendHint, + errorMessage: _errorMessage, + onSubmit: _submit, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _LoginFailureException implements Exception { + const _LoginFailureException( + this.message, { + required this.code, + required this.raw, + }); + + final String message; + final String code; + final Map raw; + + @override + String toString() => message; +} + +class _LoginForm extends StatelessWidget { + const _LoginForm({ + required this.backendUrlController, + required this.workspaceController, + required this.usernameController, + required this.passwordController, + required this.loading, + required this.backendHint, + required this.errorMessage, + required this.onSubmit, + }); + + final TextEditingController backendUrlController; + final TextEditingController workspaceController; + final TextEditingController usernameController; + final TextEditingController passwordController; + final bool loading; + final String backendHint; + final String? errorMessage; + final VoidCallback onSubmit; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: _isDarkTheme(context) ? const Color(0xFF0C1524) : Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '后端登录', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + '连接真实后端后,登录、权限菜单、用户信息和退出都会走服务端。', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: _mutedColor(context)), + ), + const SizedBox(height: 16), + TextField( + controller: backendUrlController, + decoration: InputDecoration( + labelText: '后端地址', + hintText: backendHint, + prefixIcon: const Icon(Icons.link_rounded), + ), + keyboardType: TextInputType.url, + onChanged: (_) {}, + ), + if (errorMessage != null && errorMessage!.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.12), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.red.withOpacity(0.25)), + ), + child: Text( + errorMessage!, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Colors.redAccent), + ), + ), + ], + const SizedBox(height: 20), + TextField( + controller: workspaceController, + decoration: const InputDecoration( + labelText: '工作区', + prefixIcon: Icon(Icons.domain_rounded), + ), + ), + const SizedBox(height: 14), + TextField( + controller: usernameController, + decoration: const InputDecoration( + labelText: '用户名', + prefixIcon: Icon(Icons.person_rounded), + ), + ), + const SizedBox(height: 14), + TextField( + controller: passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: '密码', + prefixIcon: Icon(Icons.lock_rounded), + ), + onSubmitted: (_) => loading ? null : onSubmit(), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: loading ? null : onSubmit, + icon: loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.login_rounded), + label: Text(loading ? '正在连接...' : '进入 Studio'), + ), + const SizedBox(height: 14), + Text( + '默认连接到后端服务,登录后会进入桌面工作台。', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: _mutedColor(context, 0.58)), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.primary.withOpacity(0.12), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon(Icons.desktop_windows_rounded, color: scheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + '打开后会进入桌面工作台,支持导航、标签页和编辑器。', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/studio_app_models.dart b/lib/studio_app_models.dart new file mode 100644 index 0000000..3926d1c --- /dev/null +++ b/lib/studio_app_models.dart @@ -0,0 +1,361 @@ +part of 'studio_app.dart'; + +const String _defaultWorkspace = 'ttx-xwms-test'; + +class StudioRuntime { + const StudioRuntime({ + required this.backendUrl, + required this.client, + required this.session, + required this.user, + required this.loginConfig, + required this.systemConfig, + required this.orgName, + required this.allowedMenuIds, + required this.profile, + }); + + final String backendUrl; + final StudioBackendClient client; + final StudioBackendSession session; + final StudioUser user; + final Map loginConfig; + final Map systemConfig; + final String orgName; + final Set allowedMenuIds; + final StudioBackendUserProfile? profile; +} + +class StudioUser { + const StudioUser({ + required this.workspace, + required this.displayName, + required this.role, + required this.languageCode, + required this.backendAppName, + required this.organizationName, + }); + + final String workspace; + final String displayName; + final String role; + final String languageCode; + final String backendAppName; + final String organizationName; +} + +class StudioActivity { + StudioActivity({required this.title, required this.detail}) + : time = DateTime.now(); + + final String title; + final String detail; + final DateTime time; +} + +class StudioTab { + StudioTab({ + required this.id, + required this.title, + required this.summary, + required this.pageType, + required this.icon, + }); + + static const String dashboardId = 'dashboard'; + + factory StudioTab.dashboard() { + return StudioTab( + id: dashboardId, + title: '工作台', + summary: 'Studio 首页和快捷入口', + pageType: StudioPageType.dashboard, + icon: Icons.space_dashboard_rounded, + ); + } + + final String id; + final String title; + final String summary; + final StudioPageType pageType; + final IconData icon; +} + +enum StudioPageType { + dashboard, + modelEditor, + jsonEditor, + navigationEditor, + manage, + history, + sync, + repair, + sqlTemplate, + sqlRunner, + portalMaintain, +} + +class StudioNavNode { + StudioNavNode({ + required this.id, + required this.title, + required this.summary, + required this.icon, + required this.pageType, + this.children = const [], + }); + + final String id; + final String title; + final String summary; + final IconData icon; + final StudioPageType pageType; + final List children; +} + +class _HistoryRow { + const _HistoryRow(this.version, this.user, this.time, this.status, this.note); + + final String version; + final String user; + final String time; + final String status; + final String note; +} + +final List studioNavTree = [ + StudioNavNode( + id: 'model-center', + title: '模型中心', + summary: '数据、单据、界面和导航', + icon: Icons.account_tree_rounded, + pageType: StudioPageType.dashboard, + children: [ + StudioNavNode( + id: 'TableModelEditor', + title: '数据模型', + summary: '字段、索引、关系与版本', + icon: Icons.table_rows_rounded, + pageType: StudioPageType.modelEditor, + ), + StudioNavNode( + id: 'BillModelEditor', + title: '单据模型', + summary: '业务单据结构和规则', + icon: Icons.description_rounded, + pageType: StudioPageType.modelEditor, + ), + StudioNavNode( + id: 'ViewModelEditor', + title: '界面模型', + summary: '界面布局和控件配置', + icon: Icons.dashboard_customize_rounded, + pageType: StudioPageType.modelEditor, + ), + StudioNavNode( + id: 'NavigationEditor', + title: '菜单维护', + summary: '导航结构和入口配置', + icon: Icons.menu_rounded, + pageType: StudioPageType.navigationEditor, + ), + ], + ), + StudioNavNode( + id: 'json-center', + title: '模型 JSON', + summary: '直写 JSON 与脚本化编辑', + icon: Icons.data_object_rounded, + pageType: StudioPageType.dashboard, + children: [ + StudioNavNode( + id: 'TableJsonEditor', + title: '数据模型 Json', + summary: 'JSON 结构和字段定义', + icon: Icons.data_object_rounded, + pageType: StudioPageType.jsonEditor, + ), + StudioNavNode( + id: 'BillJsonEditor', + title: '单据模型 Json', + summary: '单据配置的 JSON 编辑器', + icon: Icons.code_rounded, + pageType: StudioPageType.jsonEditor, + ), + StudioNavNode( + id: 'ViewJsonEditor', + title: '界面模型 Json', + summary: '界面配置的 JSON 编辑器', + icon: Icons.article_rounded, + pageType: StudioPageType.jsonEditor, + ), + StudioNavNode( + id: 'NavigatorEditor', + title: '菜单维护 Json', + summary: '导航树的 JSON 配置', + icon: Icons.list_alt_rounded, + pageType: StudioPageType.navigationEditor, + ), + ], + ), + StudioNavNode( + id: 'history', + title: '模型日志', + summary: '版本与修改历史', + icon: Icons.history_rounded, + pageType: StudioPageType.dashboard, + children: [ + StudioNavNode( + id: 'TableModelHistory', + title: '数据模型日志', + summary: '数据模型修改历史', + icon: Icons.query_builder_rounded, + pageType: StudioPageType.history, + ), + StudioNavNode( + id: 'BillModelHistory', + title: '单据模型日志', + summary: '单据模型修改历史', + icon: Icons.history_toggle_off_rounded, + pageType: StudioPageType.history, + ), + StudioNavNode( + id: 'WsoDataHistory', + title: '界面模型日志', + summary: '界面模型修改历史', + icon: Icons.event_note_rounded, + pageType: StudioPageType.history, + ), + StudioNavNode( + id: 'NavigatorHistory', + title: '菜单维护日志', + summary: '菜单配置修改历史', + icon: Icons.receipt_long_rounded, + pageType: StudioPageType.history, + ), + ], + ), + StudioNavNode( + id: 'manage', + title: '模型管理', + summary: '在线维护与发布', + icon: Icons.tune_rounded, + pageType: StudioPageType.dashboard, + children: [ + StudioNavNode( + id: 'WsoDataManage', + title: '界面模型管理', + summary: '界面模型维护', + icon: Icons.preview_rounded, + pageType: StudioPageType.manage, + ), + StudioNavNode( + id: 'BillModelManage', + title: '单据模型管理', + summary: '单据模型维护', + icon: Icons.inventory_2_rounded, + pageType: StudioPageType.manage, + ), + StudioNavNode( + id: 'TableModelManage', + title: '数据模型管理', + summary: '数据模型维护', + icon: Icons.storage_rounded, + pageType: StudioPageType.manage, + ), + ], + ), + StudioNavNode( + id: 'ModelSync', + title: '配置下载', + summary: '同步和导出配置', + icon: Icons.file_download_rounded, + pageType: StudioPageType.sync, + ), + StudioNavNode( + id: 'DataRepair', + title: '数据修复', + summary: '异常数据处理和修复', + icon: Icons.build_rounded, + pageType: StudioPageType.repair, + ), + StudioNavNode( + id: 'SqlTemplate', + title: 'SQL 模板', + summary: '查询模板和脚本管理', + icon: Icons.description_rounded, + pageType: StudioPageType.sqlTemplate, + ), + StudioNavNode( + id: 'AdvancedSqlTemplate', + title: 'SQL 模板执行', + summary: '直接执行查询模板', + icon: Icons.play_arrow_rounded, + pageType: StudioPageType.sqlRunner, + ), + StudioNavNode( + id: 'PortalMaintain', + title: 'Portal 配置', + summary: '门户和主题管理', + icon: Icons.settings_applications_rounded, + pageType: StudioPageType.portalMaintain, + ), +]; + +List _filterNavNodes( + List nodes, + Set allowedIds, +) { + if (allowedIds.isEmpty) { + return nodes; + } + + final filtered = []; + for (final node in nodes) { + final filteredChildren = node.children.isEmpty + ? const [] + : _filterNavNodes(node.children, allowedIds); + final allowed = allowedIds.contains(node.id); + if (allowed || filteredChildren.isNotEmpty) { + filtered.add( + StudioNavNode( + id: node.id, + title: node.title, + summary: node.summary, + icon: node.icon, + pageType: node.pageType, + children: filteredChildren, + ), + ); + } + } + return filtered; +} + +String _extractStringFromConfig( + Map config, + List keys, +) { + for (final key in keys) { + final value = config[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString().trim(); + } + } + return ''; +} + +String? _extractAppName(Map? config) { + if (config == null || config.isEmpty) { + return null; + } + + final candidate = _extractStringFromConfig(config, const [ + 'appName', + 'name', + 'title', + 'portalName', + 'systemName', + ]); + return candidate.isEmpty ? null : candidate; +} diff --git a/lib/studio_app_pages.dart b/lib/studio_app_pages.dart new file mode 100644 index 0000000..d0fb06f --- /dev/null +++ b/lib/studio_app_pages.dart @@ -0,0 +1,388 @@ +part of 'studio_app.dart'; + +class StudioDashboardPage extends StatelessWidget { + const StudioDashboardPage({ + super.key, + required this.tab, + required this.user, + required this.activities, + required this.rootNodes, + required this.onOpenNode, + }); + + final StudioTab tab; + final StudioUser user; + final List activities; + final List rootNodes; + final ValueChanged onOpenNode; + + @override + Widget build(BuildContext context) { + final quickNodes = _allLeaves(rootNodes).take(6).toList(); + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of( + context, + ).colorScheme.primary.withOpacity(0.25), + Theme.of( + context, + ).colorScheme.tertiary.withOpacity(0.20), + ], + ), + borderRadius: BorderRadius.circular(24), + ), + child: const Icon( + Icons.space_dashboard_rounded, + size: 38, + ), + ), + const SizedBox(width: 18), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '工作台', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + '左侧菜单已映射自 studio.html 的核心入口,点击即可打开多标签页。', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Colors.white70), + ), + ], + ), + ), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _MiniBadge(text: user.workspace), + _MiniBadge(text: user.languageCode.toUpperCase()), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 14), + LayoutBuilder( + builder: (context, constraints) { + final cardWidth = constraints.maxWidth >= 1100 + ? (constraints.maxWidth - 32) / 3 + : constraints.maxWidth >= 760 + ? (constraints.maxWidth - 16) / 2 + : constraints.maxWidth; + + return Wrap( + spacing: 16, + runSpacing: 16, + children: [ + SizedBox( + width: cardWidth, + child: _InfoPanel( + title: '会话状态', + value: user.displayName, + icon: Icons.person_rounded, + subtitle: '${user.role} · 已登录', + ), + ), + SizedBox( + width: cardWidth, + child: _InfoPanel( + title: '打开页面', + value: '${activities.length} 条记录', + icon: Icons.tab_rounded, + subtitle: '支持多标签并行工作', + ), + ), + SizedBox( + width: cardWidth, + child: _InfoPanel( + title: '运行方式', + value: '桌面端', + icon: Icons.desktop_windows_rounded, + subtitle: 'Flutter Windows / Web 共用', + ), + ), + ], + ); + }, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '快捷入口', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: quickNodes + .map( + (node) => ActionChip( + avatar: Icon(node.icon, size: 18), + label: Text(node.title), + onPressed: () => onOpenNode(node), + ), + ) + .toList(), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, constraints) { + final leftWidth = constraints.maxWidth >= 980 + ? (constraints.maxWidth - 16) * 0.58 + : constraints.maxWidth; + final rightWidth = constraints.maxWidth >= 980 + ? (constraints.maxWidth - 16) * 0.42 + : constraints.maxWidth; + + if (constraints.maxWidth >= 980) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: leftWidth, + child: _ActivityPanel(activities: activities), + ), + const SizedBox(width: 16), + SizedBox( + width: rightWidth, + child: _FeaturePanel(onOpenNode: onOpenNode), + ), + ], + ); + } + + return Column( + children: [ + _ActivityPanel(activities: activities), + const SizedBox(height: 16), + _FeaturePanel(onOpenNode: onOpenNode), + ], + ); + }, + ), + ], + ), + ), + ); + } + + List _allLeaves(List nodes) { + final result = []; + for (final node in nodes) { + if (node.children.isEmpty) { + result.add(node); + } else { + result.addAll(_allLeaves(node.children)); + } + } + return result; + } +} + +class _InfoPanel extends StatelessWidget { + const _InfoPanel({ + required this.title, + required this.value, + required this.icon, + required this.subtitle, + }); + + final String title; + final String value; + final IconData icon; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Icon(icon), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Colors.white60), + ), + const SizedBox(height: 6), + Text( + value, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Colors.white70), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ActivityPanel extends StatelessWidget { + const _ActivityPanel({required this.activities}); + + final List activities; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '最近活动', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + ...activities.take(8).map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.bolt_rounded, size: 18), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + item.detail, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Colors.white70), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _FeaturePanel extends StatelessWidget { + const _FeaturePanel({required this.onOpenNode}); + + final ValueChanged onOpenNode; + + @override + Widget build(BuildContext context) { + final nodes = _allLeaves(studioNavTree); + + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '功能覆盖', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + const Text( + '当前 Flutter 桌面端实现了 Studio 页面的核心交互:登录、导航、标签页、JSON/SQL 编辑、日志和运维任务。', + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: nodes + .take(5) + .map( + (node) => FilledButton.tonal( + onPressed: () => onOpenNode(node), + child: Text(node.title), + ), + ) + .toList(), + ), + ], + ), + ), + ); + } + + List _allLeaves(List nodes) { + final result = []; + for (final node in nodes) { + if (node.children.isEmpty) { + result.add(node); + } else { + result.addAll(_allLeaves(node.children)); + } + } + return result; + } +} diff --git a/lib/studio_app_pages_form_editor.dart b/lib/studio_app_pages_form_editor.dart new file mode 100644 index 0000000..c7af625 --- /dev/null +++ b/lib/studio_app_pages_form_editor.dart @@ -0,0 +1,825 @@ +part of 'studio_app.dart'; + +class StudioFormEditorPage extends StatefulWidget { + const StudioFormEditorPage({ + super.key, + required this.tab, + required this.runtime, + required this.onAction, + }); + + final StudioTab tab; + final StudioRuntime runtime; + final void Function(String title, String detail) onAction; + + @override + State createState() => _StudioFormEditorPageState(); +} + +class _StudioFormEditorPageState extends State { + late final TextEditingController _nameController; + late final TextEditingController _codeController; + late final TextEditingController _versionController; + late final TextEditingController _remarkController; + late final TextEditingController _contentController; + bool _loading = true; + bool _saving = false; + bool _loadedExisting = false; + String _status = '加载中'; + String _detail = ''; + List> _options = >[]; + String _selectedKey = ''; + + bool get _isTableMode => + widget.tab.id.contains('Table') || widget.tab.id.contains('table'); + + bool get _isBillMode => + widget.tab.id.contains('Bill') || widget.tab.id.contains('bill'); + + bool get _isViewMode => + widget.tab.id.contains('View') || + widget.tab.id.contains('WsoData') || + widget.tab.id.contains('view'); + + bool get _isNavigatorMode => + widget.tab.id.contains('Navigator') || + widget.tab.id.contains('Navigation') || + widget.tab.id.contains('navigator') || + widget.tab.id.contains('navigation'); + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.tab.title); + _codeController = TextEditingController(text: widget.tab.id); + _versionController = TextEditingController(text: '1'); + _remarkController = TextEditingController(text: widget.tab.summary); + _contentController = TextEditingController(text: '{}'); + _loadInitialData(); + } + + @override + void dispose() { + _nameController.dispose(); + _codeController.dispose(); + _versionController.dispose(); + _remarkController.dispose(); + _contentController.dispose(); + super.dispose(); + } + + Future _loadInitialData() async { + if (_isTableMode) { + await _loadTableLikeOptions(); + return; + } + if (_isBillMode) { + await _loadBillLikeOptions(); + return; + } + if (_isViewMode) { + await _loadViewOptions(); + return; + } + if (_isNavigatorMode) { + await _loadNavigator(); + return; + } + setState(() { + _contentController.text = _prettyJson(_defaultPayload()); + _status = '可直接编辑'; + _loading = false; + }); + } + + Future _loadTableLikeOptions() async { + try { + final items = await widget.runtime.client.fetchTableModels( + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + setState(() { + _options = items; + _selectedKey = items.isNotEmpty ? _itemKey(items.first) : ''; + }); + if (_selectedKey.isNotEmpty) { + await _loadTableLikeItem(); + } else { + setState(() { + _loading = false; + _status = '没有找到数据模型'; + _contentController.text = _prettyJson(_defaultPayload()); + }); + } + } catch (error, stackTrace) { + _reportError('加载数据模型失败', error, stackTrace); + } + } + + Future _loadBillLikeOptions() async { + try { + final items = await widget.runtime.client.fetchBillModels( + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + setState(() { + _options = items; + _selectedKey = items.isNotEmpty ? _itemKey(items.first) : ''; + }); + if (_selectedKey.isNotEmpty) { + await _loadBillLikeItem(); + } else { + setState(() { + _loading = false; + _status = '没有找到单据模型'; + _contentController.text = _prettyJson(_defaultPayload()); + }); + } + } catch (error, stackTrace) { + _reportError('加载单据模型失败', error, stackTrace); + } + } + + Future _loadViewOptions() async { + try { + final items = await widget.runtime.client.fetchViewModelOptions( + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + setState(() { + _options = items; + _selectedKey = items.isNotEmpty ? _viewItemKey(items.first) : ''; + }); + if (_selectedKey.isNotEmpty) { + await _loadViewItem(); + } else { + setState(() { + _loading = false; + _status = '没有找到界面模型'; + _contentController.text = _prettyJson(_defaultPayload()); + }); + } + } catch (error, stackTrace) { + _reportError('加载界面模型失败', error, stackTrace); + } + } + + Future _loadNavigator() async { + try { + final navigator = await widget.runtime.client.fetchCreationNavigatorRecord( + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + final structure = navigator['structure'] is List + ? List>.from( + (navigator['structure'] as List).whereType(), + ) + : >[]; + setState(() { + _nameController.text = widget.tab.title; + _codeController.text = widget.tab.id; + _versionController.text = _readValue(navigator, const ['version'], '1'); + _remarkController.text = widget.tab.summary; + _contentController.text = _prettyJson(navigator.isNotEmpty + ? navigator + : {'structure': structure}); + _status = '导航数据已加载'; + _detail = 'root items: ${structure.length}'; + _loadedExisting = true; + _loading = false; + }); + } catch (error, stackTrace) { + _reportError('加载导航失败', error, stackTrace); + } + } + + Future _loadTableLikeItem() async { + final code = _selectedKey; + if (code.isEmpty) { + return; + } + setState(() { + _loading = true; + _status = '正在加载数据模型...'; + }); + try { + final item = await widget.runtime.client.fetchTableModel( + code: code, + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + if (item == null) { + setState(() { + _status = '模型不存在'; + _detail = code; + _loading = false; + }); + return; + } + _applyLoadedItem(item, code: _readValue(item, const ['code'], code)); + } catch (error, stackTrace) { + _reportError('加载数据模型失败', error, stackTrace); + } + } + + Future _loadBillLikeItem() async { + final code = _selectedKey; + if (code.isEmpty) { + return; + } + setState(() { + _loading = true; + _status = '正在加载单据模型...'; + }); + try { + final item = await widget.runtime.client.fetchBillModel( + code: code, + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + if (item == null) { + setState(() { + _status = '模型不存在'; + _detail = code; + _loading = false; + }); + return; + } + _applyLoadedItem(item, code: _readValue(item, const ['code'], code)); + } catch (error, stackTrace) { + _reportError('加载单据模型失败', error, stackTrace); + } + } + + Future _loadViewItem() async { + final parts = _selectedKey.split('::'); + if (parts.length != 2) { + return; + } + final tid = parts[0]; + final oid = parts[1]; + setState(() { + _loading = true; + _status = '正在加载界面模型...'; + }); + try { + final item = await widget.runtime.client.fetchViewModel( + tid: tid, + oid: oid, + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + if (item == null) { + setState(() { + _status = '模型不存在'; + _detail = '$tid / $oid'; + _loading = false; + }); + return; + } + _applyLoadedItem( + item, + code: _readValue(item, const ['tid', 'code'], tid), + version: _readValue(item, const ['oid', 'version'], oid), + ); + } catch (error, stackTrace) { + _reportError('加载界面模型失败', error, stackTrace); + } + } + + void _applyLoadedItem( + Map item, { + required String code, + String? version, + }) { + setState(() { + _nameController.text = _readValue(item, const ['name', 'title'], widget.tab.title); + _codeController.text = code; + _versionController.text = version ?? _readValue(item, const ['version', 'oid'], '1'); + _remarkController.text = _readValue(item, const ['remark', 'description', 'summary'], widget.tab.summary); + _contentController.text = _prettyJson(item); + _status = '已加载'; + _detail = code; + _loadedExisting = true; + _loading = false; + }); + } + + Map _defaultPayload() { + return { + 'name': widget.tab.title, + 'code': widget.tab.id, + 'version': '1', + 'remark': widget.tab.summary, + 'enabled': true, + }; + } + + String _itemKey(Map item) { + return _readValue(item, const ['code', 'id']); + } + + String _viewItemKey(Map item) { + final tid = _readValue(item, const ['tid']); + final oid = _readValue(item, const ['oid']); + if (tid.isEmpty || oid.isEmpty) { + return ''; + } + return '$tid::$oid'; + } + + String _readValue(Map item, List keys, [String fallback = '']) { + for (final key in keys) { + final value = item[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString(); + } + } + return fallback; + } + + String _prettyJson(dynamic value) { + try { + return const JsonEncoder.withIndent(' ').convert(value); + } catch (_) { + return value.toString(); + } + } + + void _reportError(String title, Object error, StackTrace stackTrace) { + debugPrint('========== $title =========='); + debugPrint('error: $error'); + debugPrint('stackTrace:\n$stackTrace'); + debugPrint('========== $title end =========='); + if (!mounted) { + return; + } + setState(() { + _status = '$title'; + _detail = error.toString().replaceFirst('Bad state: ', ''); + _loading = false; + }); + } + + Future _save() async { + final parsed = _decodeContent(); + if (parsed == null) { + setState(() { + _status = 'JSON 格式错误'; + _detail = '请先修正内容后再保存'; + }); + return; + } + setState(() { + _saving = true; + _status = _loadedExisting ? '正在更新...' : '正在创建...'; + _detail = ''; + }); + try { + final wasExisting = _loadedExisting; + final result = await _saveByMode(parsed); + if (!mounted) { + return; + } + setState(() { + _loadedExisting = true; + _status = result.success ? '保存成功' : '保存失败'; + _detail = _prettyJson(result.raw); + }); + widget.onAction(wasExisting ? '更新' : '创建', widget.tab.title); + } catch (error, stackTrace) { + _reportError('保存失败', error, stackTrace); + } finally { + if (mounted) { + setState(() { + _saving = false; + }); + } + } + } + + Future _saveByMode(dynamic parsed) async { + if (_isNavigatorMode) { + final map = parsed is Map + ? Map.from(parsed) + : {'structure': parsed}; + final structure = map['structure'] is List + ? List>.from( + (map['structure'] as List).whereType(), + ) + : >[]; + final version = int.tryParse(_versionController.text.trim()) ?? 1; + return widget.runtime.client.saveCreationNavigator( + data: structure, + version: version, + session: widget.runtime.session, + ); + } + + final payload = parsed is Map + ? Map.from(parsed) + : {'data': parsed}; + payload['name'] = _nameController.text.trim(); + payload['code'] = _codeController.text.trim(); + payload['version'] = _versionController.text.trim(); + payload['remark'] = _remarkController.text.trim(); + + if (_isViewMode) { + payload['tid'] = _codeController.text.trim(); + payload['oid'] = _versionController.text.trim(); + } + + if (_isTableMode) { + return _loadedExisting + ? widget.runtime.client.updateTableModel( + payload: payload, + session: widget.runtime.session, + ) + : widget.runtime.client.createTableModel( + payload: payload, + session: widget.runtime.session, + ); + } + + if (_isBillMode) { + return _loadedExisting + ? widget.runtime.client.updateBillModel( + payload: payload, + session: widget.runtime.session, + ) + : widget.runtime.client.createBillModel( + payload: payload, + session: widget.runtime.session, + ); + } + + if (_isViewMode) { + return _loadedExisting + ? widget.runtime.client.updateViewModel( + payload: payload, + session: widget.runtime.session, + ) + : widget.runtime.client.createViewModel( + payload: payload, + session: widget.runtime.session, + ); + } + + return StudioBackendActionResult( + success: true, + code: '', + message: '无需保存', + data: payload, + raw: payload, + ); + } + + dynamic _decodeContent() { + try { + return jsonDecode(_contentController.text); + } catch (_) { + return null; + } + } + + void _formatContent() { + final parsed = _decodeContent(); + if (parsed == null) { + setState(() { + _status = 'JSON 格式错误,无法格式化'; + }); + return; + } + setState(() { + _contentController.text = _prettyJson(parsed); + _status = 'JSON 已格式化'; + }); + widget.onAction('格式化 JSON', widget.tab.title); + } + + Future _reload() async { + if (_isTableMode) { + await _loadTableLikeItem(); + return; + } + if (_isBillMode) { + await _loadBillLikeItem(); + return; + } + if (_isViewMode) { + await _loadViewItem(); + return; + } + if (_isNavigatorMode) { + await _loadNavigator(); + return; + } + } + + Future _delete() async { + if (!_loadedExisting) { + setState(() { + _status = '当前内容还没有保存过,无需删除'; + }); + return; + } + try { + StudioBackendActionResult result; + if (_isTableMode) { + result = await widget.runtime.client.deleteTableModel( + code: _codeController.text.trim(), + session: widget.runtime.session, + ); + } else if (_isBillMode) { + result = await widget.runtime.client.deleteBillModel( + code: _codeController.text.trim(), + session: widget.runtime.session, + ); + } else if (_isViewMode) { + result = await widget.runtime.client.deleteViewModel( + tid: _codeController.text.trim(), + oid: _versionController.text.trim(), + session: widget.runtime.session, + ); + } else { + setState(() { + _status = '当前页面不支持删除'; + }); + return; + } + if (!mounted) { + return; + } + setState(() { + _status = result.success ? '删除成功' : '删除失败'; + _detail = _prettyJson(result.raw); + }); + widget.onAction('删除', widget.tab.title); + } catch (error, stackTrace) { + _reportError('删除失败', error, stackTrace); + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 980; + final leftPanel = _buildEditorPanel(context); + final rightPanel = _buildPreviewPanel(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (wide) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: leftPanel), + const SizedBox(width: 16), + Expanded(child: rightPanel), + ], + ) + else ...[ + leftPanel, + const SizedBox(height: 16), + rightPanel, + ], + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + Expanded( + child: SelectableText( + _status, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + FilledButton.tonalIcon( + onPressed: _loading || _saving ? null : _reload, + icon: const Icon(Icons.refresh_rounded), + label: const Text('重新加载'), + ), + const SizedBox(width: 10), + FilledButton.tonalIcon( + onPressed: _loading || _saving ? null : _formatContent, + icon: const Icon(Icons.data_object_rounded), + label: const Text('格式化'), + ), + const SizedBox(width: 10), + FilledButton.icon( + onPressed: _loading || _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_rounded), + label: Text(_loadedExisting ? '更新' : '保存'), + ), + const SizedBox(width: 10), + if (!_isNavigatorMode) + FilledButton.tonalIcon( + onPressed: _loading || _saving ? null : _delete, + icon: const Icon(Icons.delete_rounded), + label: const Text('删除'), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ); + } + + Widget _buildEditorPanel(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '属性编辑', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 14), + TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: '名称', + prefixIcon: Icon(Icons.title_rounded), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _codeController, + decoration: InputDecoration( + labelText: _isViewMode ? 'tid' : '编码', + prefixIcon: const Icon(Icons.code_rounded), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _versionController, + decoration: InputDecoration( + labelText: _isViewMode ? 'oid' : '版本', + prefixIcon: const Icon(Icons.sell_rounded), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _remarkController, + maxLines: 3, + decoration: const InputDecoration( + labelText: '说明', + alignLabelWithHint: true, + prefixIcon: Icon(Icons.notes_rounded), + ), + ), + const SizedBox(height: 14), + if (_options.isNotEmpty && !_isNavigatorMode) ...[ + DropdownButtonFormField( + value: _selectedKey.isNotEmpty ? _selectedKey : null, + decoration: InputDecoration( + labelText: _isViewMode ? '选择 tid / oid' : '选择模型', + ), + items: _options.map((item) { + final value = _isViewMode ? _viewItemKey(item) : _itemKey(item); + final label = _isViewMode + ? '${_readValue(item, const ['tid'])} / ${_readValue(item, const ['oid'])}' + : _readValue(item, const ['code', 'id']); + return DropdownMenuItem( + value: value, + child: Text(label), + ); + }).toList(), + onChanged: (value) { + if (value == null || value.isEmpty) { + return; + } + setState(() { + _selectedKey = value; + }); + _reload(); + }, + ), + const SizedBox(height: 12), + ], + Text( + 'JSON 内容', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 10), + SizedBox( + height: 360, + child: TextField( + controller: _contentController, + expands: true, + maxLines: null, + minLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontFamily: 'monospace'), + decoration: const InputDecoration( + hintText: '编辑或粘贴 JSON', + alignLabelWithHint: true, + prefixIcon: Icon(Icons.description_rounded), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPreviewPanel(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '预览与原文', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 14), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _softPanelColor(context), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: SelectableText( + _prettyJson(_decodeContent() ?? _currentPreview()), + style: const TextStyle(fontFamily: 'monospace', height: 1.5), + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _MiniBadge(text: widget.tab.pageType.name), + _MiniBadge(text: widget.tab.id), + _MiniBadge(text: _loadedExisting ? '已加载' : '新建'), + ], + ), + const SizedBox(height: 14), + SelectableText( + _detail.isEmpty ? '暂无详情' : _detail, + style: const TextStyle(fontFamily: 'monospace', height: 1.45), + ), + ], + ), + ), + ); + } + + dynamic _currentPreview() { + if (_isNavigatorMode) { + return { + 'structure': >[], + 'version': _versionController.text.trim(), + }; + } + return { + 'name': _nameController.text.trim(), + 'code': _codeController.text.trim(), + 'version': _versionController.text.trim(), + 'remark': _remarkController.text.trim(), + }; + } +} + diff --git a/lib/studio_app_pages_history.dart b/lib/studio_app_pages_history.dart new file mode 100644 index 0000000..350507c --- /dev/null +++ b/lib/studio_app_pages_history.dart @@ -0,0 +1,192 @@ +part of 'studio_app.dart'; + +class StudioHistoryPage extends StatefulWidget { + const StudioHistoryPage({ + super.key, + required this.tab, + required this.runtime, + required this.onAction, + }); + + final StudioTab tab; + final StudioRuntime runtime; + final void Function(String title, String detail) onAction; + + @override + State createState() => _StudioHistoryPageState(); +} + +class _StudioHistoryPageState extends State { + bool _loading = true; + String? _errorMessage; + List> _rows = >[]; + + @override + void initState() { + super.initState(); + _reload(); + } + + Future _reload() async { + setState(() { + _loading = true; + _errorMessage = null; + }); + try { + final rows = await widget.runtime.client.fetchUpgradeLogs( + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + setState(() { + _rows = rows; + }); + } catch (error, stackTrace) { + _printError('加载历史失败', error, stackTrace); + if (!mounted) { + return; + } + setState(() { + _errorMessage = _normalizeError(error); + }); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + void _printError(String title, Object error, StackTrace stackTrace) { + debugPrint('========== $title =========='); + debugPrint('error: $error'); + debugPrint('stackTrace:\n$stackTrace'); + debugPrint('========== $title end =========='); + } + + String _normalizeError(Object error) { + final text = error.toString(); + const marker = 'Bad state: '; + if (text.startsWith(marker)) { + return text.substring(marker.length); + } + return text; + } + + String _value(Map row, List keys, [String fallback = '']) { + for (final key in keys) { + final value = row[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString(); + } + } + return fallback; + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.tab.title, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + '从后端读取发布与修改历史。', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: _mutedColor(context)), + ), + ], + ), + ), + FilledButton.tonalIcon( + onPressed: _loading ? null : _reload, + icon: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh_rounded), + label: const Text('刷新'), + ), + ], + ), + const SizedBox(height: 12), + if (_errorMessage != null) ...[ + SelectableText( + _errorMessage!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + const SizedBox(height: 12), + ], + if (_loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 28), + child: Center(child: CircularProgressIndicator()), + ) + else + SizedBox( + width: double.infinity, + child: DataTable( + columns: const [ + DataColumn(label: Text('版本')), + DataColumn(label: Text('操作者')), + DataColumn(label: Text('时间')), + DataColumn(label: Text('状态')), + DataColumn(label: Text('说明')), + ], + rows: _rows + .map( + (row) => DataRow( + cells: [ + DataCell(Text(_value(row, const ['version', 'tag', 'name'], ''))), + DataCell(Text(_value(row, const ['user', 'operator', 'creator'], ''))), + DataCell(Text(_value(row, const ['time', 'createTime', 'date'], ''))), + DataCell(Text(_value(row, const ['status', 'state'], ''))), + DataCell( + SelectableText( + _value( + row, + const ['note', 'msg', 'message', 'summary', 'desc'], + row.toString(), + ), + ), + ), + ], + ), + ) + .toList(), + ), + ), + const SizedBox(height: 8), + Text( + '共 ${_rows.length} 条历史记录', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: _mutedColor(context, 0.62)), + ), + ], + ), + ), + ), + ); + } +} + diff --git a/lib/studio_app_pages_operations.dart b/lib/studio_app_pages_operations.dart new file mode 100644 index 0000000..45d2ba3 --- /dev/null +++ b/lib/studio_app_pages_operations.dart @@ -0,0 +1,266 @@ +part of 'studio_app.dart'; + +class StudioOperationsPage extends StatefulWidget { + const StudioOperationsPage({ + super.key, + required this.tab, + required this.runtime, + required this.onAction, + }); + + final StudioTab tab; + final StudioRuntime runtime; + final void Function(String title, String detail) onAction; + + @override + State createState() => _StudioOperationsPageState(); +} + +class _StudioOperationsPageState extends State { + bool _loading = false; + String _status = '等待执行'; + String _detail = ''; + Map? _lastData; + + Future _downloadConfigs() async { + setState(() { + _loading = true; + _status = '正在下载配置包...'; + _detail = ''; + _lastData = null; + }); + try { + final result = await widget.runtime.client.downloadModelConfigs( + session: widget.runtime.session, + ); + final savedPath = await saveBytesToFile( + bytes: result.bytes, + suggestedName: result.filename, + ); + if (!mounted) { + return; + } + setState(() { + _status = savedPath == null + ? '已下载配置包,但未保存' + : '配置包已保存'; + _detail = savedPath == null + ? '文件名:${result.filename}\n大小:${result.bytes.length} bytes' + : '文件:$savedPath\n文件名:${result.filename}\n大小:${result.bytes.length} bytes'; + _lastData = { + 'filename': result.filename, + 'contentType': result.contentType, + 'statusCode': result.statusCode, + 'savedPath': savedPath, + 'size': result.bytes.length, + }; + }); + widget.onAction('下载配置', result.filename); + } catch (error, stackTrace) { + _printError('下载配置失败', error, stackTrace); + if (!mounted) { + return; + } + setState(() { + _status = '下载失败'; + _detail = _normalizeError(error); + }); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + Future _repairDepartment() async { + setState(() { + _loading = true; + _status = '正在执行部门修复...'; + _detail = ''; + _lastData = null; + }); + try { + final result = await widget.runtime.client.repairDepartment( + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + setState(() { + _status = result.success ? '修复完成' : '修复失败'; + _detail = _formatActionResult(result); + _lastData = result.raw; + }); + widget.onAction('部门修复', result.message.isNotEmpty ? result.message : '已执行'); + } catch (error, stackTrace) { + _printError('部门修复失败', error, stackTrace); + if (!mounted) { + return; + } + setState(() { + _status = '修复失败'; + _detail = _normalizeError(error); + }); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + void _printError(String title, Object error, StackTrace stackTrace) { + debugPrint('========== $title =========='); + debugPrint('error: $error'); + debugPrint('stackTrace:\n$stackTrace'); + debugPrint('========== $title end =========='); + } + + String _normalizeError(Object error) { + final text = error.toString(); + const marker = 'Bad state: '; + if (text.startsWith(marker)) { + return text.substring(marker.length); + } + return text; + } + + String _formatActionResult(StudioBackendActionResult result) { + final buffer = StringBuffer() + ..writeln('code: ${result.code.isEmpty ? '空' : result.code}') + ..writeln('message: ${result.message.isEmpty ? '无' : result.message}') + ..writeln('data: ${const JsonEncoder.withIndent(' ').convert(result.data)}') + ..writeln('raw: ${const JsonEncoder.withIndent(' ').convert(result.raw)}'); + return buffer.toString(); + } + + @override + Widget build(BuildContext context) { + final isRepair = widget.tab.pageType == StudioPageType.repair; + + final primaryAction = isRepair ? _repairDepartment : _downloadConfigs; + final primaryLabel = isRepair ? '执行修复' : '下载配置'; + final primaryIcon = isRepair ? Icons.build_rounded : Icons.download_rounded; + final secondaryHint = isRepair + ? '部门修复会直接调用后端接口,执行后请刷新相关页面确认数据。' + : '下载配置会调用后端压缩包接口,并让你选择本地保存位置。'; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.tab.title, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + widget.tab.summary, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: _mutedColor(context)), + ), + const SizedBox(height: 16), + LinearProgressIndicator( + value: _loading ? null : 1.0, + minHeight: 10, + borderRadius: BorderRadius.circular(999), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '操作说明', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text(secondaryHint), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: _loading ? null : primaryAction, + icon: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(primaryIcon), + label: Text(primaryLabel), + ), + FilledButton.tonalIcon( + onPressed: _loading + ? null + : () => widget.onAction('记录动作', widget.tab.title), + icon: const Icon(Icons.history_rounded), + label: const Text('记录动作'), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '执行结果', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 10), + SelectableText( + _status, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + if (_detail.isNotEmpty) ...[ + const SizedBox(height: 10), + SelectableText( + _detail, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + if (_lastData != null) ...[ + const SizedBox(height: 12), + SelectableText( + const JsonEncoder.withIndent(' ').convert(_lastData), + style: const TextStyle(fontFamily: 'monospace', height: 1.45), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} + diff --git a/lib/studio_app_pages_portal.dart b/lib/studio_app_pages_portal.dart new file mode 100644 index 0000000..9d60f78 --- /dev/null +++ b/lib/studio_app_pages_portal.dart @@ -0,0 +1,150 @@ +part of 'studio_app.dart'; + +class StudioPortalMaintainPage extends StatefulWidget { + const StudioPortalMaintainPage({ + super.key, + required this.tab, + required this.runtime, + required this.onAction, + }); + + final StudioTab tab; + final StudioRuntime runtime; + final void Function(String title, String detail) onAction; + + @override + State createState() => _StudioPortalMaintainPageState(); +} + +class _StudioPortalMaintainPageState extends State { + bool _loading = true; + String _status = '正在加载门户'; + List> _rows = >[]; + + @override + void initState() { + super.initState(); + _reload(); + } + + Future _reload() async { + setState(() { + _loading = true; + _status = '正在加载门户'; + }); + try { + final rows = await widget.runtime.client.fetchPortalConfigs( + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + setState(() { + _rows = rows; + _status = '门户已加载'; + }); + widget.onAction('加载门户', widget.tab.title); + } catch (error, stackTrace) { + debugPrint('========== 加载门户失败 =========='); + debugPrint('error: $error'); + debugPrint('stackTrace:\n$stackTrace'); + debugPrint('========== 加载门户失败 end =========='); + if (!mounted) { + return; + } + setState(() { + _status = error.toString(); + }); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + String _value(Map row, List keys, [String fallback = '']) { + for (final key in keys) { + final value = row[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString(); + } + } + return fallback; + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.tab.title, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + '读取后端 portal 配置列表。', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: _mutedColor(context)), + ), + ], + ), + ), + FilledButton.tonalIcon( + onPressed: _loading ? null : _reload, + icon: const Icon(Icons.refresh_rounded), + label: const Text('刷新'), + ), + ], + ), + const SizedBox(height: 12), + SelectableText(_status), + const SizedBox(height: 12), + if (_loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else + DataTable( + columns: const [ + DataColumn(label: Text('编码')), + DataColumn(label: Text('名称')), + DataColumn(label: Text('描述')), + ], + rows: _rows + .map( + (row) => DataRow( + cells: [ + DataCell(Text(_value(row, const ['id', 'code']))), + DataCell(Text(_value(row, const ['label', 'name']))), + DataCell( + SelectableText( + _value(row, const ['remark', 'description', 'portal']), + ), + ), + ], + ), + ) + .toList(), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/studio_app_pages_sql.dart b/lib/studio_app_pages_sql.dart new file mode 100644 index 0000000..558b952 --- /dev/null +++ b/lib/studio_app_pages_sql.dart @@ -0,0 +1,662 @@ +part of 'studio_app.dart'; + +class StudioSqlPage extends StatefulWidget { + const StudioSqlPage({ + super.key, + required this.tab, + required this.runtime, + required this.onAction, + }); + + final StudioTab tab; + final StudioRuntime runtime; + final void Function(String title, String detail) onAction; + + @override + State createState() => _StudioSqlPageState(); +} + +class _StudioSqlPageState extends State { + late final TextEditingController _sqlController; + late final TextEditingController _limitController; + late final TextEditingController _countSqlController; + late final TextEditingController _summarySqlController; + late final TextEditingController _criteriaController; + late final TextEditingController _extConfigController; + bool _loading = true; + bool _templateLoaded = false; + String _status = '等待加载'; + String _detail = ''; + Map> _templateIndex = >{}; + List> _resultRows = >[]; + List _resultColumns = []; + String _selectedTidValue = ''; + String _selectedOidValue = ''; + + bool get _isTemplateMode => widget.tab.pageType == StudioPageType.sqlTemplate; + + @override + void initState() { + super.initState(); + _sqlController = TextEditingController( + text: + 'select id, code, name from ttx_demo_table order by id desc limit 20;', + ); + _limitController = TextEditingController(text: '100'); + _countSqlController = TextEditingController(); + _summarySqlController = TextEditingController(); + _criteriaController = TextEditingController(text: '{}'); + _extConfigController = TextEditingController(text: '{}'); + if (_isTemplateMode) { + _loadTemplateIndex(); + } else { + _loading = false; + _status = '可直接执行 SQL'; + } + } + + @override + void dispose() { + _sqlController.dispose(); + _limitController.dispose(); + _countSqlController.dispose(); + _summarySqlController.dispose(); + _criteriaController.dispose(); + _extConfigController.dispose(); + super.dispose(); + } + + Future _loadTemplateIndex() async { + try { + final items = await widget.runtime.client.fetchSqlTemplateIndex( + session: widget.runtime.session, + ); + final index = >{}; + for (final item in items) { + final tid = _readValue(item, const ['tid']); + final oid = _readValue(item, const ['oid']); + if (tid.isEmpty || oid.isEmpty) { + continue; + } + index.putIfAbsent(tid, () => []).add(oid); + } + if (!mounted) { + return; + } + setState(() { + _templateIndex = index; + _status = index.isEmpty ? '没有找到 SQL 模板' : '已加载模板索引'; + _loading = false; + }); + if (index.isNotEmpty) { + await _loadSelectedTemplate(); + } + } catch (error, stackTrace) { + _printError('加载 SQL 模板索引失败', error, stackTrace); + if (!mounted) { + return; + } + setState(() { + _status = '加载模板失败'; + _detail = _normalizeError(error); + _loading = false; + }); + } + } + + String _selectedTid() { + if (_selectedTidValue.isNotEmpty) { + return _selectedTidValue; + } + if (_templateIndex.isEmpty) { + return ''; + } + return _templateIndex.keys.first; + } + + String _selectedOid([String? tid]) { + final selectedTid = tid ?? _selectedTid(); + final oids = _templateIndex[selectedTid]; + if (oids == null || oids.isEmpty) { + return ''; + } + if (_selectedOidValue.isNotEmpty && oids.contains(_selectedOidValue)) { + return _selectedOidValue; + } + return oids.first; + } + + Future _loadSelectedTemplate() async { + final tid = _selectedTid(); + final oid = _selectedOid(tid); + if (tid.isEmpty || oid.isEmpty) { + return; + } + setState(() { + _loading = true; + _status = '正在加载模板...'; + }); + try { + final template = await widget.runtime.client.fetchSqlTemplate( + tid: tid, + oid: oid, + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + if (template == null) { + setState(() { + _templateLoaded = false; + _status = '模板不存在'; + _detail = '$tid / $oid'; + }); + return; + } + setState(() { + _templateLoaded = true; + _sqlController.text = _readValue(template, const ['value', 'sql']); + _countSqlController.text = _readValue(template, const ['countSql', 'count']); + _summarySqlController.text = _readValue(template, const ['summarySql', 'summary']); + _criteriaController.text = _readValue(template, const ['criteria'], '{}'); + _extConfigController.text = _readValue(template, const ['extConfig'], '{}'); + _status = '模板已加载'; + _detail = '$tid / $oid'; + }); + widget.onAction('加载模板', '$tid / $oid'); + } catch (error, stackTrace) { + _printError('加载 SQL 模板失败', error, stackTrace); + if (!mounted) { + return; + } + setState(() { + _templateLoaded = false; + _status = '加载模板失败'; + _detail = _normalizeError(error); + }); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + Future _runSql() async { + setState(() { + _loading = true; + _status = '正在执行 SQL...'; + _detail = ''; + _resultRows = >[]; + _resultColumns = []; + }); + try { + final limit = int.tryParse(_limitController.text.trim()) ?? 100; + final result = await widget.runtime.client.runSql( + sql: _sqlController.text, + limit: limit, + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + final data = result.data is Map + ? Map.from(result.data as Map) + : {}; + final rows = _extractRows(data['list'] ?? data['rows'] ?? data['data']); + setState(() { + _resultRows = rows; + _resultColumns = rows.isEmpty ? [] : rows.first.keys.toList(); + _status = result.success ? 'SQL 执行完成' : 'SQL 执行失败'; + _detail = _formatActionResult(result); + }); + widget.onAction('执行 SQL', widget.tab.title); + } catch (error, stackTrace) { + _printError('执行 SQL 失败', error, stackTrace); + if (!mounted) { + return; + } + setState(() { + _status = 'SQL 执行失败'; + _detail = _normalizeError(error); + }); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + Future _saveTemplate() async { + final tid = _selectedTid(); + final oid = _selectedOid(tid); + if (tid.isEmpty || oid.isEmpty) { + setState(() { + _status = '请选择 tid 和 oid'; + }); + return; + } + setState(() { + _loading = true; + _status = _templateLoaded ? '正在更新模板...' : '正在创建模板...'; + _detail = ''; + }); + try { + final payload = { + 'value': _sqlController.text, + 'count': _countSqlController.text, + 'criteria': _criteriaController.text, + 'summary': _summarySqlController.text, + 'extConfig': _extConfigController.text, + }; + final wasLoaded = _templateLoaded; + final result = _templateLoaded + ? await widget.runtime.client.updateSqlTemplate( + tid: tid, + oid: oid, + payload: payload, + session: widget.runtime.session, + ) + : await widget.runtime.client.createSqlTemplate( + tid: tid, + oid: oid, + payload: payload, + session: widget.runtime.session, + ); + if (!mounted) { + return; + } + setState(() { + _templateLoaded = true; + _status = result.success ? '模板已保存' : '保存失败'; + _detail = _formatActionResult(result); + }); + widget.onAction(wasLoaded ? '更新模板' : '创建模板', '$tid / $oid'); + } catch (error, stackTrace) { + _printError('保存 SQL 模板失败', error, stackTrace); + if (!mounted) { + return; + } + setState(() { + _status = '保存失败'; + _detail = _normalizeError(error); + }); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + void _formatSql() { + setState(() { + _sqlController.text = _sqlController.text + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + }); + widget.onAction('格式化 SQL', widget.tab.title); + } + + void _printError(String title, Object error, StackTrace stackTrace) { + debugPrint('========== $title =========='); + debugPrint('error: $error'); + debugPrint('stackTrace:\n$stackTrace'); + debugPrint('========== $title end =========='); + } + + String _normalizeError(Object error) { + final text = error.toString(); + const marker = 'Bad state: '; + if (text.startsWith(marker)) { + return text.substring(marker.length); + } + return text; + } + + String _readValue(Map map, List keys, [String fallback = '']) { + for (final key in keys) { + final value = map[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString(); + } + } + return fallback; + } + + List> _extractRows(dynamic value) { + if (value is List) { + return value + .whereType() + .map((item) => Map.from(item)) + .toList(); + } + return >[]; + } + + String _formatActionResult(StudioBackendActionResult result) { + return const JsonEncoder.withIndent(' ').convert({ + 'code': result.code, + 'message': result.message, + 'data': result.data, + 'raw': result.raw, + }); + } + + Widget _buildTemplateBody(BuildContext context) { + final tids = _templateIndex.keys.toList(); + final selectedTid = _selectedTid(); + final oids = selectedTid.isEmpty + ? const [] + : _templateIndex[selectedTid] ?? const []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: 260, + child: DropdownButtonFormField( + value: tids.contains(selectedTid) ? selectedTid : null, + decoration: const InputDecoration(labelText: 'tid'), + items: tids + .map((tid) => DropdownMenuItem(value: tid, child: Text(tid))) + .toList(), + onChanged: (value) { + if (value == null) { + return; + } + setState(() { + _selectedTidValue = value; + _selectedOidValue = (_templateIndex[value] ?? const []).isNotEmpty + ? _templateIndex[value]!.first + : ''; + }); + }, + ), + ), + SizedBox( + width: 260, + child: DropdownButtonFormField( + value: oids.contains(_selectedOid()) ? _selectedOid() : null, + decoration: const InputDecoration(labelText: 'oid'), + items: oids + .map((oid) => DropdownMenuItem(value: oid, child: Text(oid))) + .toList(), + onChanged: (value) { + if (value == null) { + return; + } + setState(() { + _selectedOidValue = value; + }); + }, + ), + ), + FilledButton.tonalIcon( + onPressed: _loading ? null : _loadSelectedTemplate, + icon: const Icon(Icons.folder_open_rounded), + label: const Text('加载模板'), + ), + FilledButton.icon( + onPressed: _loading ? null : _saveTemplate, + icon: const Icon(Icons.save_rounded), + label: Text(_templateLoaded ? '更新模板' : '创建模板'), + ), + ], + ), + const SizedBox(height: 14), + _buildSqlEditors(context, includeTemplateFields: true), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (_isTemplateMode && _selectedTidValue.isEmpty && _templateIndex.isNotEmpty) { + _selectedTidValue = _templateIndex.keys.first; + _selectedOidValue = _templateIndex[_selectedTidValue]!.first; + } + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.tab.title, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + _isTemplateMode ? '从后端加载 SQL 模板并支持创建、更新。' : '直接执行 SQL 并查看结果。', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: _mutedColor(context)), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + if (_loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + else if (_isTemplateMode) + _buildTemplateBody(context) + else + _buildSqlEditors(context, includeTemplateFields: false), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '执行状态', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + if (!_isTemplateMode) + FilledButton.tonalIcon( + onPressed: _loading ? null : _runSql, + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('执行 SQL'), + ), + if (!_isTemplateMode) const SizedBox(width: 10), + FilledButton.tonalIcon( + onPressed: _loading ? null : _formatSql, + icon: const Icon(Icons.format_align_left_rounded), + label: const Text('格式化'), + ), + if (_isTemplateMode) ...[ + const SizedBox(width: 10), + FilledButton.tonalIcon( + onPressed: _loading ? null : _saveTemplate, + icon: const Icon(Icons.save_rounded), + label: const Text('保存模板'), + ), + ], + ], + ), + const SizedBox(height: 10), + SelectableText(_status), + if (_detail.isNotEmpty) ...[ + const SizedBox(height: 10), + SelectableText( + _detail, + style: const TextStyle(fontFamily: 'monospace', height: 1.45), + ), + ], + ], + ), + ), + ), + if (_resultRows.isNotEmpty) ...[ + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '执行结果', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columns: _resultColumns + .map((column) => DataColumn(label: Text(column))) + .toList(), + rows: _resultRows + .map( + (row) => DataRow( + cells: _resultColumns + .map( + (column) => DataCell( + SelectableText(_formatCellValue(row[column])), + ), + ) + .toList(), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildSqlEditors(BuildContext context, {required bool includeTemplateFields}) { + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (includeTemplateFields) ...[ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: 220, + child: TextField( + controller: _countSqlController, + decoration: const InputDecoration(labelText: 'count SQL'), + maxLines: 3, + ), + ), + SizedBox( + width: 220, + child: TextField( + controller: _summarySqlController, + decoration: const InputDecoration(labelText: 'summary SQL'), + maxLines: 3, + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _criteriaController, + decoration: const InputDecoration( + labelText: 'criteria', + alignLabelWithHint: true, + ), + maxLines: 3, + ), + const SizedBox(height: 12), + TextField( + controller: _extConfigController, + decoration: const InputDecoration( + labelText: 'extConfig', + alignLabelWithHint: true, + ), + maxLines: 3, + ), + const SizedBox(height: 14), + ], + SizedBox( + height: 280, + child: TextField( + controller: _sqlController, + expands: true, + maxLines: null, + minLines: null, + keyboardType: TextInputType.multiline, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontFamily: 'monospace'), + decoration: const InputDecoration( + labelText: 'SQL 内容', + alignLabelWithHint: true, + ), + ), + ), + if (!includeTemplateFields) ...[ + const SizedBox(height: 12), + Row( + children: [ + SizedBox( + width: 160, + child: TextField( + controller: _limitController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'limit'), + ), + ), + const SizedBox(width: 12), + FilledButton.tonalIcon( + onPressed: _loading ? null : _formatSql, + icon: const Icon(Icons.format_align_left_rounded), + label: const Text('格式化'), + ), + ], + ), + ], + ], + ), + ), + ); + } + + String _formatCellValue(Object? value) { + if (value == null) { + return ''; + } + if (value is Map || value is List) { + return const JsonEncoder.withIndent(' ').convert(value); + } + return value.toString(); + } +} + diff --git a/lib/studio_app_shell.dart b/lib/studio_app_shell.dart new file mode 100644 index 0000000..aa261ae --- /dev/null +++ b/lib/studio_app_shell.dart @@ -0,0 +1,268 @@ +part of 'studio_app.dart'; + +class StudioShell extends StatefulWidget { + const StudioShell({ + super.key, + required this.runtime, + required this.languageCode, + required this.themeMode, + required this.onToggleTheme, + required this.onLogout, + }); + + final StudioRuntime runtime; + final String languageCode; + final ThemeMode themeMode; + final VoidCallback onToggleTheme; + final VoidCallback onLogout; + + @override + State createState() => _StudioShellState(); +} + +class _StudioShellState extends State { + final List _activities = []; + final List _tabs = [StudioTab.dashboard()]; + String _searchQuery = ''; + String _selectedTabId = StudioTab.dashboardId; + int _refreshRevision = 0; + + StudioUser get user => widget.runtime.user; + + @override + void initState() { + super.initState(); + _activities.add( + StudioActivity( + title: '登录成功', + detail: '${user.displayName} / ${user.workspace}', + ), + ); + } + + StudioTab get _selectedTab => + _tabs.firstWhere((tab) => tab.id == _selectedTabId); + + void _log(String title, String detail) { + setState(() { + _activities.insert(0, StudioActivity(title: title, detail: detail)); + if (_activities.length > 24) { + _activities.removeLast(); + } + }); + } + + void _openNode(StudioNavNode node) { + final existingIndex = _tabs.indexWhere((tab) => tab.id == node.id); + setState(() { + if (existingIndex == -1) { + _tabs.add( + StudioTab( + id: node.id, + title: node.title, + summary: node.summary, + pageType: node.pageType, + icon: node.icon, + ), + ); + } + _selectedTabId = node.id; + }); + _log('打开页面', node.title); + } + + void _selectTab(String tabId) { + setState(() { + _selectedTabId = tabId; + }); + } + + void _closeTab(String tabId) { + if (tabId == StudioTab.dashboardId) { + return; + } + setState(() { + _tabs.removeWhere((tab) => tab.id == tabId); + if (_selectedTabId == tabId) { + _selectedTabId = _tabs.isNotEmpty + ? _tabs.last.id + : StudioTab.dashboardId; + } + }); + _log('关闭页面', tabId); + } + + void _refreshPage() { + setState(() { + _refreshRevision += 1; + }); + _log('刷新页面', _selectedTab.title); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('已刷新 ${_selectedTab.title}'))); + } + + void _handlePageAction(String title, String detail) { + _log(title, detail); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(detail))); + } + + List _allLeaves(List nodes) { + final result = []; + for (final node in nodes) { + if (node.children.isEmpty) { + result.add(node); + } else { + result.addAll(_allLeaves(node.children)); + } + } + return result; + } + + @override + Widget build(BuildContext context) { + final wide = MediaQuery.of(context).size.width >= 1080; + final visibleRoots = _filterNavNodes( + studioNavTree, + widget.runtime.allowedMenuIds, + ); + final leaves = _allLeaves(visibleRoots); + final filteredLeaves = _searchQuery.trim().isEmpty + ? leaves + : leaves + .where( + (node) => + node.title.contains(_searchQuery) || + node.summary.contains(_searchQuery), + ) + .toList(); + + return Scaffold( + appBar: wide + ? null + : AppBar( + title: const Text('TTX Studio'), + actions: [ + IconButton( + tooltip: widget.themeMode == ThemeMode.dark + ? '切换亮色主题' + : '切换暗色主题', + onPressed: widget.onToggleTheme, + icon: Icon( + widget.themeMode == ThemeMode.dark + ? Icons.light_mode_rounded + : Icons.dark_mode_rounded, + ), + ), + IconButton( + tooltip: '刷新', + onPressed: _refreshPage, + icon: const Icon(Icons.refresh_rounded), + ), + IconButton( + tooltip: '退出登录', + onPressed: widget.onLogout, + icon: const Icon(Icons.logout_rounded), + ), + ], + ), + drawer: wide + ? null + : Drawer( + child: SafeArea( + child: StudioSidebar( + user: user, + themeMode: widget.themeMode, + onToggleTheme: widget.onToggleTheme, + searchQuery: _searchQuery, + filteredLeaves: filteredLeaves, + rootNodes: visibleRoots, + onSearchChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + onOpenNode: (node) { + Navigator.of(context).pop(); + _openNode(node); + }, + onLogout: widget.onLogout, + ), + ), + ), + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: _shellGradientColors(context), + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: wide + ? Row( + children: [ + SizedBox( + width: 336, + child: StudioSidebar( + user: user, + themeMode: widget.themeMode, + onToggleTheme: widget.onToggleTheme, + searchQuery: _searchQuery, + filteredLeaves: filteredLeaves, + rootNodes: visibleRoots, + onSearchChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + onOpenNode: _openNode, + onLogout: widget.onLogout, + ), + ), + Expanded( + child: StudioWorkspaceView( + runtime: widget.runtime, + user: user, + selectedTab: _selectedTab, + tabs: List.unmodifiable(_tabs), + activities: List.unmodifiable( + _activities, + ), + rootNodes: visibleRoots, + refreshToken: _refreshRevision, + onSelectTab: _selectTab, + onCloseTab: _closeTab, + onRefresh: _refreshPage, + onLogout: widget.onLogout, + themeMode: widget.themeMode, + onToggleTheme: widget.onToggleTheme, + onPageAction: _handlePageAction, + onOpenNode: _openNode, + ), + ), + ], + ) + : StudioWorkspaceView( + runtime: widget.runtime, + user: user, + selectedTab: _selectedTab, + tabs: List.unmodifiable(_tabs), + activities: List.unmodifiable(_activities), + rootNodes: visibleRoots, + refreshToken: _refreshRevision, + onSelectTab: _selectTab, + onCloseTab: _closeTab, + onRefresh: _refreshPage, + onLogout: widget.onLogout, + themeMode: widget.themeMode, + onToggleTheme: widget.onToggleTheme, + onPageAction: _handlePageAction, + onOpenNode: _openNode, + ), + ), + ), + ); + } +} + diff --git a/lib/studio_app_shell_sidebar.dart b/lib/studio_app_shell_sidebar.dart new file mode 100644 index 0000000..d1d6442 --- /dev/null +++ b/lib/studio_app_shell_sidebar.dart @@ -0,0 +1,309 @@ +part of 'studio_app.dart'; + +class StudioSidebar extends StatelessWidget { + const StudioSidebar({ + super.key, + required this.user, + required this.themeMode, + required this.onToggleTheme, + required this.searchQuery, + required this.filteredLeaves, + required this.rootNodes, + required this.onSearchChanged, + required this.onOpenNode, + required this.onLogout, + }); + + final StudioUser user; + final ThemeMode themeMode; + final VoidCallback onToggleTheme; + final String searchQuery; + final List filteredLeaves; + final List rootNodes; + final ValueChanged onSearchChanged; + final ValueChanged onOpenNode; + final VoidCallback onLogout; + + @override + Widget build(BuildContext context) { + return Container( + color: _isDarkTheme(context) ? const Color(0xFF0A1320) : Colors.white, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: _SidebarHero( + user: user, + themeMode: themeMode, + onToggleTheme: onToggleTheme, + onLogout: onLogout, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: TextField( + onChanged: onSearchChanged, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search_rounded), + hintText: '搜索菜单或功能', + ), + ), + ), + const SizedBox(height: 16), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 16), + children: [ + if (searchQuery.trim().isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: Text( + '搜索结果', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: _mutedColor(context), + ), + ), + ), + ...filteredLeaves.map( + (node) => + _LeafNavTile(node: node, onTap: () => onOpenNode(node)), + ), + if (filteredLeaves.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Text('没有匹配的菜单项'), + ), + ] else ...[ + _QuickLaunchStrip( + nodes: _allLeaves(rootNodes).take(4).toList(), + onOpenNode: onOpenNode, + ), + const SizedBox(height: 12), + ...rootNodes.map( + (node) => _TreeNode(node: node, onLeafTap: onOpenNode), + ), + ], + ], + ), + ), + ], + ), + ); + } + + List _allLeaves(List nodes) { + final result = []; + for (final node in nodes) { + if (node.children.isEmpty) { + result.add(node); + } else { + result.addAll(_allLeaves(node.children)); + } + } + return result; + } +} + +class _SidebarHero extends StatelessWidget { + const _SidebarHero({ + required this.user, + required this.themeMode, + required this.onToggleTheme, + required this.onLogout, + }); + + final StudioUser user; + final ThemeMode themeMode; + final VoidCallback onToggleTheme; + final VoidCallback onLogout; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + gradient: LinearGradient( + colors: [ + scheme.primary.withOpacity(0.18), + scheme.tertiary.withOpacity(0.10), + _isDarkTheme(context) ? const Color(0xFF101B2D) : Colors.white, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.all(color: scheme.outlineVariant.withOpacity(0.7)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: scheme.primary.withOpacity(0.16), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.dashboard_customize_rounded, + color: scheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'TTX Studio', + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + Text( + 'Desktop Shell', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: _mutedColor(context, 0.60)), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + user.displayName, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + '${user.workspace} · ${user.role}', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: _mutedColor(context)), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MiniBadge(text: user.languageCode.toUpperCase()), + if (user.backendAppName.isNotEmpty) + _MiniBadge(text: user.backendAppName), + if (user.organizationName.isNotEmpty) + _MiniBadge(text: user.organizationName), + const _MiniBadge(text: 'Windows'), + const _MiniBadge(text: 'Backend'), + ], + ), + const SizedBox(height: 14), + FilledButton.tonalIcon( + onPressed: onLogout, + icon: const Icon(Icons.logout_rounded), + label: const Text('退出登录'), + ), + const SizedBox(height: 8), + _ThemeToggleButton(themeMode: themeMode, onPressed: onToggleTheme), + ], + ), + ); + } +} + +class _QuickLaunchStrip extends StatelessWidget { + const _QuickLaunchStrip({required this.nodes, required this.onOpenNode}); + + final List nodes; + final ValueChanged onOpenNode; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Text( + '快速入口', + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(color: _mutedColor(context)), + ), + ), + ...nodes.map( + (node) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _LeafNavTile(node: node, onTap: () => onOpenNode(node)), + ), + ), + ], + ); + } +} + +class _TreeNode extends StatelessWidget { + const _TreeNode({required this.node, required this.onLeafTap}); + + final StudioNavNode node; + final ValueChanged onLeafTap; + + @override + Widget build(BuildContext context) { + if (node.children.isEmpty) { + return _LeafNavTile(node: node, onTap: () => onLeafTap(node)); + } + + return ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + childrenPadding: const EdgeInsets.only(left: 14, right: 2, bottom: 6), + leading: CircleAvatar( + radius: 16, + backgroundColor: Theme.of(context).colorScheme.primaryContainer + .withOpacity(_isDarkTheme(context) ? 0.16 : 0.55), + child: Icon(node.icon, size: 18), + ), + title: Text(node.title), + subtitle: Text(node.summary), + children: node.children + .map((child) => _TreeNode(node: child, onLeafTap: onLeafTap)) + .toList(), + ); + } +} + +class _LeafNavTile extends StatelessWidget { + const _LeafNavTile({required this.node, required this.onTap}); + + final StudioNavNode node; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + leading: CircleAvatar( + radius: 16, + backgroundColor: Theme.of(context).colorScheme.primaryContainer + .withOpacity(_isDarkTheme(context) ? 0.16 : 0.55), + child: Icon(node.icon, size: 18), + ), + title: Text(node.title), + subtitle: Text( + node.summary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: onTap, + ), + ); + } +} + diff --git a/lib/studio_app_shell_workspace.dart b/lib/studio_app_shell_workspace.dart new file mode 100644 index 0000000..4260a87 --- /dev/null +++ b/lib/studio_app_shell_workspace.dart @@ -0,0 +1,354 @@ +part of 'studio_app.dart'; + +class StudioWorkspaceView extends StatelessWidget { + const StudioWorkspaceView({ + super.key, + required this.runtime, + required this.user, + required this.selectedTab, + required this.tabs, + required this.activities, + required this.rootNodes, + required this.refreshToken, + required this.onSelectTab, + required this.onCloseTab, + required this.onRefresh, + required this.onLogout, + required this.themeMode, + required this.onToggleTheme, + required this.onPageAction, + required this.onOpenNode, + }); + + final StudioRuntime runtime; + final StudioUser user; + final StudioTab selectedTab; + final List tabs; + final List activities; + final List rootNodes; + final int refreshToken; + final ValueChanged onSelectTab; + final ValueChanged onCloseTab; + final VoidCallback onRefresh; + final VoidCallback onLogout; + final ThemeMode themeMode; + final VoidCallback onToggleTheme; + final void Function(String title, String detail) onPageAction; + final ValueChanged onOpenNode; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _WorkspaceHeader( + user: user, + selectedTab: selectedTab, + onRefresh: onRefresh, + onLogout: onLogout, + themeMode: themeMode, + onToggleTheme: onToggleTheme, + ), + const SizedBox(height: 14), + _TabStrip( + tabs: tabs, + selectedTabId: selectedTab.id, + onSelectTab: onSelectTab, + onCloseTab: onCloseTab, + ), + const SizedBox(height: 14), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + child: _buildPage( + key: ValueKey('${selectedTab.id}:$refreshToken'), + tab: selectedTab, + activities: activities, + rootNodes: rootNodes, + runtime: runtime, + onPageAction: onPageAction, + onOpenNode: onOpenNode, + ), + ), + ), + const SizedBox(height: 14), + _StudioStatusBar( + user: user, + selectedTab: selectedTab, + tabsCount: tabs.length, + activitiesCount: activities.length, + ), + ], + ), + ); + } + + Widget _buildPage({ + required Key key, + required StudioTab tab, + required List activities, + required List rootNodes, + required StudioRuntime runtime, + required void Function(String title, String detail) onPageAction, + required ValueChanged onOpenNode, + }) { + switch (tab.pageType) { + case StudioPageType.dashboard: + return StudioDashboardPage( + key: key, + tab: tab, + user: user, + activities: activities, + rootNodes: rootNodes, + onOpenNode: onOpenNode, + ); + case StudioPageType.history: + return StudioHistoryPage( + key: key, + tab: tab, + runtime: runtime, + onAction: onPageAction, + ); + case StudioPageType.sync: + case StudioPageType.repair: + return StudioOperationsPage( + key: key, + tab: tab, + runtime: runtime, + onAction: onPageAction, + ); + case StudioPageType.sqlTemplate: + case StudioPageType.sqlRunner: + return StudioSqlPage( + key: key, + tab: tab, + runtime: runtime, + onAction: onPageAction, + ); + case StudioPageType.portalMaintain: + return StudioPortalMaintainPage( + key: key, + tab: tab, + runtime: runtime, + onAction: onPageAction, + ); + default: + return StudioFormEditorPage( + key: key, + tab: tab, + runtime: runtime, + onAction: onPageAction, + ); + } + } +} + +class _WorkspaceHeader extends StatelessWidget { + const _WorkspaceHeader({ + required this.user, + required this.selectedTab, + required this.onRefresh, + required this.onLogout, + required this.themeMode, + required this.onToggleTheme, + }); + + final StudioUser user; + final StudioTab selectedTab; + final VoidCallback onRefresh; + final VoidCallback onLogout; + final ThemeMode themeMode; + final VoidCallback onToggleTheme; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + selectedTab.title, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + selectedTab.summary, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: _mutedColor(context)), + ), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _MiniBadge(text: user.workspace), + _MiniBadge(text: user.displayName), + if (user.backendAppName.isNotEmpty) + _MiniBadge(text: user.backendAppName), + if (user.organizationName.isNotEmpty) + _MiniBadge(text: user.organizationName), + _MiniBadge(text: selectedTab.pageType.name), + ], + ), + ], + ), + ), + const SizedBox(width: 16), + Wrap( + spacing: 10, + runSpacing: 10, + alignment: WrapAlignment.end, + children: [ + _ThemeToggleButton( + themeMode: themeMode, + onPressed: onToggleTheme, + ), + FilledButton.tonalIcon( + onPressed: onRefresh, + icon: const Icon(Icons.refresh_rounded), + label: const Text('刷新'), + ), + FilledButton.tonalIcon( + onPressed: onLogout, + icon: const Icon(Icons.logout_rounded), + label: const Text('退出'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _TabStrip extends StatelessWidget { + const _TabStrip({ + required this.tabs, + required this.selectedTabId, + required this.onSelectTab, + required this.onCloseTab, + }); + + final List tabs; + final String selectedTabId; + final ValueChanged onSelectTab; + final ValueChanged onCloseTab; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 58, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: tabs.length, + separatorBuilder: (_, __) => const SizedBox(width: 10), + itemBuilder: (context, index) { + final tab = tabs[index]; + final selected = tab.id == selectedTabId; + final scheme = Theme.of(context).colorScheme; + return InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () => onSelectTab(tab.id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 210, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: selected + ? scheme.primary.withOpacity(0.18) + : _softPanelColor(context), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: selected + ? scheme.primary.withOpacity(0.55) + : scheme.outlineVariant.withOpacity(0.8), + ), + ), + child: Row( + children: [ + Icon(tab.icon, size: 18), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tab.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + tab.pageType.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: _mutedColor(context, 0.62)), + ), + ], + ), + ), + if (tab.id != StudioTab.dashboardId) + IconButton( + tooltip: '关闭', + onPressed: () => onCloseTab(tab.id), + icon: const Icon(Icons.close_rounded, size: 16), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +class _StudioStatusBar extends StatelessWidget { + const _StudioStatusBar({ + required this.user, + required this.selectedTab, + required this.tabsCount, + required this.activitiesCount, + }); + + final StudioUser user; + final StudioTab selectedTab; + final int tabsCount; + final int activitiesCount; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + child: Wrap( + spacing: 16, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _MiniBadge(text: '用户 ${user.displayName}'), + _MiniBadge(text: '工作区 ${user.workspace}'), + if (user.backendAppName.isNotEmpty) + _MiniBadge(text: '应用 ${user.backendAppName}'), + if (user.organizationName.isNotEmpty) + _MiniBadge(text: '机构 ${user.organizationName}'), + _MiniBadge(text: '当前页 ${selectedTab.title}'), + _MiniBadge(text: '标签 ${tabsCount}'), + _MiniBadge(text: '活动 ${activitiesCount}'), + ], + ), + ), + ); + } +} diff --git a/lib/studio_app_theme.dart b/lib/studio_app_theme.dart new file mode 100644 index 0000000..c953434 --- /dev/null +++ b/lib/studio_app_theme.dart @@ -0,0 +1,135 @@ +part of 'studio_app.dart'; + +bool _isDarkTheme(BuildContext context) { + return Theme.of(context).brightness == Brightness.dark; +} + +Color _mutedColor(BuildContext context, [double opacity = 0.68]) { + return Theme.of(context).colorScheme.onSurface.withOpacity(opacity); +} + +Color _softPanelColor(BuildContext context) { + return _isDarkTheme(context) + ? const Color(0xFF0A1320) + : const Color(0xFFF1F6FA); +} + +List _loginGradientColors(BuildContext context) { + return _isDarkTheme(context) + ? const [Color(0xFF060B13), Color(0xFF0D1726), Color(0xFF07121D)] + : const [Color(0xFFF8FBFF), Color(0xFFEFF8F7), Color(0xFFF6F8FB)]; +} + +List _shellGradientColors(BuildContext context) { + return _isDarkTheme(context) + ? const [Color(0xFF07111A), Color(0xFF091623), Color(0xFF07111A)] + : const [Color(0xFFF8FBFF), Color(0xFFEFF6FF), Color(0xFFF7FAFC)]; +} + +ThemeData buildStudioTheme(Brightness brightness) { + final colorScheme = ColorScheme.fromSeed( + seedColor: const Color(0xFF1AA6A6), + brightness: brightness, + ); + final isDark = brightness == Brightness.dark; + final inputFill = isDark + ? const Color(0xFF0E1726) + : colorScheme.surfaceContainerHighest; + + return ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: colorScheme, + scaffoldBackgroundColor: isDark + ? const Color(0xFF07101A) + : const Color(0xFFF6F8FB), + appBarTheme: AppBarTheme( + centerTitle: false, + backgroundColor: isDark ? const Color(0xFF07111A) : const Color(0xFFF8FBFF), + foregroundColor: colorScheme.onSurface, + elevation: 0, + ), + cardTheme: CardThemeData( + color: isDark ? const Color(0xFF101B2D) : Colors.white, + elevation: isDark ? 0 : 1, + surfaceTintColor: colorScheme.surfaceTint, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + drawerTheme: DrawerThemeData( + backgroundColor: isDark ? const Color(0xFF07111A) : Colors.white, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: inputFill, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: colorScheme.primary), + ), + ), + ); +} + +class _MiniBadge extends StatelessWidget { + const _MiniBadge({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _isDarkTheme(context) + ? Colors.white.withOpacity(0.06) + : scheme.primaryContainer.withOpacity(0.45), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: _mutedColor(context, 0.78)), + ), + ); + } +} + +class _ThemeToggleButton extends StatelessWidget { + const _ThemeToggleButton({required this.themeMode, required this.onPressed}); + + final ThemeMode themeMode; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final isDark = themeMode == ThemeMode.dark; + return FilledButton.tonalIcon( + onPressed: onPressed, + icon: Icon(isDark ? Icons.light_mode_rounded : Icons.dark_mode_rounded), + label: Text(isDark ? '亮色主题' : '暗色主题'), + ); + } +} + +class _GlowCircle extends StatelessWidget { + const _GlowCircle({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 240, + height: 240, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..b33de3b --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..3e82bc4 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner 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 "ttx_page_admin") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.ttx_page_admin") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +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() + +# 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_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +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) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +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. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..8dd8f71 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +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. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# 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/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter 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. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..789034b --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +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} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# 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 application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..b00e89d --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..80d3d71 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "ttx_page_admin"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "ttx_page_admin"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..0a1ac7a --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..17f8763 --- /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..a6df7fe --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#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..a6df7fe --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3cb6de0 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*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 */ + 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 */; }; +/* 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 */ + 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 /* ttx_page_admin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ttx_page_admin.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 = ""; }; + 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 = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + 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 */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* ttx_page_admin.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 = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 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 = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* ttx_page_admin.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 */ + 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"; + }; +/* 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; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ttxPageAdmin.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ttx_page_admin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ttx_page_admin"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ttxPageAdmin.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ttx_page_admin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ttx_page_admin"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ttxPageAdmin.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ttx_page_admin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ttx_page_admin"; + }; + 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..a488f57 --- /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..60833df --- /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..e011932 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..a488f57 --- /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..30a506b --- /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..917b31d --- /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..640daab 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..060a73a 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..3854589 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..92969e4 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..f9f240a 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..1e3faec 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..c84b435 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..89422eb --- /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..3e4a01e --- /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 = ttx_page_admin + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.ttxPageAdmin + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..7d1aa2e --- /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..0018f18 --- /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..3c859cc --- /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..1cf3e1a --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..1c972a8 --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + 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 + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3ac8e7b --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + 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..3b1f88e --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..e82ddff --- /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.yaml b/pubspec.yaml new file mode 100644 index 0000000..847d155 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,94 @@ +name: ttx_page_admin +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.11.5 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + crypto: ^3.0.6 + dio: ^5.8.0 + file_selector: ^1.0.3 + pointycastle: ^3.9.1 + shared_preferences: ^2.3.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/run_windows.err b/run_windows.err new file mode 100644 index 0000000..473a0f4 diff --git a/run_windows.out b/run_windows.out new file mode 100644 index 0000000..473a0f4 diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..0f894b4 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:ttx_page_admin/main.dart'; + +Future _startMockBackend() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + + server.listen((request) async { + final path = request.uri.pathSegments.join('/'); + + Future writeJson(Map body) async { + request.response.headers.contentType = ContentType.json; + request.response.write(jsonEncode(body)); + await request.response.close(); + } + + if (request.method == 'GET' && path == 'rest/auth/loginPageConfig') { + await writeJson({ + 'code': '0', + 'data': { + 'server': 'http://127.0.0.1:${server.port}/', + 'db': 'studio', + 'appName': 'TTX Studio', + }, + }); + return; + } + + if (request.method == 'POST' && path == 'rest/auth/login') { + await writeJson({ + 'code': '0', + 'message': 'ok', + 'leftDays': 30, + 'session': { + 'user': 'admin', + 'db': 'studio', + 'tk': 'token-1', + 'client': 'client-1', + 'locale': 'zh-CN', + 'timezone': 'Asia/Shanghai', + 'portal': 'studio', + }, + 'appConfig': {'appName': 'TTX Studio'}, + 'systemConfig': {'showDashboard': true}, + }); + return; + } + + if (request.method == 'GET' && path == 'user/getUser') { + await writeJson({ + 'code': '0', + 'data': { + 'code': 'admin', + 'name': '管理员', + 'isDeveloper': true, + 'portal': 'studio', + }, + }); + return; + } + + if (request.method == 'POST' && path == 'cbt/org/byUser') { + await writeJson({ + 'code': '0', + 'data': { + 'code': 'org-1', + 'name': '测试组织', + }, + }); + return; + } + + if (request.method == 'POST' && path == 'studio/access/permissions') { + await writeJson({ + 'code': '0', + 'data': [ + {'id': 'model-center'}, + {'id': 'json-center'}, + {'id': 'history'}, + {'id': 'manage'}, + {'id': 'ModelSync'}, + {'id': 'DataRepair'}, + {'id': 'SqlTemplate'}, + {'id': 'AdvancedSqlTemplate'}, + {'id': 'PortalMaintain'}, + ], + }); + return; + } + + if (request.method == 'GET' && path == 'rest/auth/logout') { + await writeJson({'code': '0'}); + return; + } + + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + }); + + return server; +} + +void main() { + testWidgets('Studio desktop login opens workspace', (WidgetTester tester) async { + final server = await _startMockBackend(); + addTearDown(() async { + await server.close(force: true); + }); + + await tester.binding.setSurfaceSize(const Size(1440, 1200)); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + await tester.pumpWidget(const StudioApp()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).at(0), 'http://127.0.0.1:${server.port}/'); + await tester.enterText(find.byType(TextField).at(1), 'studio'); + await tester.enterText(find.byType(TextField).at(2), 'admin'); + await tester.enterText(find.byType(TextField).at(3), '123456'); + await tester.tap(find.text('进入 Studio')); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(StudioShell), findsOneWidget); + expect(find.byType(StudioWorkspaceView), findsOneWidget); + expect(find.text('测试组织'), findsWidgets); + }); +} diff --git a/ttx_page_admin.iml b/ttx_page_admin.iml new file mode 100644 index 0000000..da716b8 --- /dev/null +++ b/ttx_page_admin.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..ed94478 Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..1045b69 Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..cbc1461 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..ec400a7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..8f6f65a Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..09eed14 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + ttx_page_admin + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..1a9c936 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "ttx_page_admin", + "short_name": "ttx_page_admin", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d8be602 --- /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..f9f0e77 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(ttx_page_admin 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 "ttx_page_admin") + +# 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..f94413a --- /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/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..370a677 --- /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..239737d --- /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.example" "\0" + VALUE "FileDescription", "TTX Studio" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "TTX Studio" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "TTX Studio.exe" "\0" + VALUE "ProductName", "TTX Studio" "\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..340fe79 --- /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..c464b5d --- /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..b5c6051 --- /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(1440, 900); + if (!window.Create(L"TTX Studio", 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..13fd79e --- /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..0f983ed 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..f243ba6 --- /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..21130f0 --- /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..c9cbf4a --- /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..c6efacb --- /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..799e3df --- /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_