commit 347d2644375cc6e129ddef680699f4f5451118ec Author: Sibuxiangx Date: Thu Nov 13 09:14:49 2025 +0800 😋 初始化仓库 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..257ec1f --- /dev/null +++ b/.metadata @@ -0,0 +1,39 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: android + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: ios + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: web + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: windows + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..76b0af5 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# loveace_autojudge + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..a76e12b --- /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 = "meow.loveace.autojudge.loveace_autojudge" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "meow.loveace.autojudge.loveace_autojudge" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..83723ca --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/meow/loveace/autojudge/loveace_autojudge/MainActivity.kt b/android/app/src/main/kotlin/meow/loveace/autojudge/loveace_autojudge/MainActivity.kt new file mode 100644 index 0000000..26490b9 --- /dev/null +++ b/android/app/src/main/kotlin/meow/loveace/autojudge/loveace_autojudge/MainActivity.kt @@ -0,0 +1,5 @@ +package meow.loveace.autojudge.loveace_autojudge + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /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.12-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..fb605bc --- /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.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..efbc5e0 Binary files /dev/null and b/assets/logo.png differ diff --git a/fonts/MiSans-Regular.otf b/fonts/MiSans-Regular.otf new file mode 100644 index 0000000..149f52b Binary files /dev/null and b/fonts/MiSans-Regular.otf differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /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..592ceee --- /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..c271805 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 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 = ""; }; + 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 */, + 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 */, + ); + 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 = meow.loveace.autojudge.loveaceAutojudge; + 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 = meow.loveace.autojudge.loveaceAutojudge.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 = meow.loveace.autojudge.loveaceAutojudge.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 = meow.loveace.autojudge.loveaceAutojudge.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 = meow.loveace.autojudge.loveaceAutojudge; + 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 = meow.loveace.autojudge.loveaceAutojudge; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..c7f0956 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,58 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Loveace Autojudge + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + loveace_autojudge + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + NSUserNotificationsUsageDescription + 需要通知权限以便在评教过程中向您发送进度更新 + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/iss.iss b/iss.iss new file mode 100644 index 0000000..e34d54b --- /dev/null +++ b/iss.iss @@ -0,0 +1,59 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "自动评教小工具" +#define MyAppVersion "0.1.4" +#define MyAppPublisher "LoveACETeam, Org." +#define MyAppURL "https://docs.loveace.linota.cn" +#define MyAppExeName "loveace_autojudge.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{0D750C03-70D5-4DE5-AC00-55BB31FA4226} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\LoveACETeam\AutoJudge +UninstallDisplayIcon={app}\{#MyAppExeName} +; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run +; on anything but x64 and Windows 11 on Arm. +ArchitecturesAllowed=x64compatible +; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the +; install be done in "64-bit mode" on x64 or Windows 11 on Arm, +; meaning it should use the native 64-bit Program Files directory and +; the 64-bit view of the registry. +ArchitecturesInstallIn64BitMode=x64compatible +DisableProgramGroupPage=yes +; Uncomment the following line to run in non administrative install mode (install for current user only). +;PrivilegesRequired=lowest +OutputDir=C:\Users\LinNian\loveace_autojudge\build\inno +OutputBaseFilename=loveace_autojudge +SetupIconFile=C:\Users\LinNian\loveace_autojudge\logo.ico +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" + +[Files] +Source: "C:\Users\LinNian\loveace_autojudge\build\windows\x64\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +Source: "C:\Users\LinNian\loveace_autojudge\build\windows\x64\runner\Release\flutter_secure_storage_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "C:\Users\LinNian\loveace_autojudge\build\windows\x64\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "C:\Users\LinNian\loveace_autojudge\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..8961a69 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,137 @@ +import 'dart:io' show Platform; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'providers/auth_provider.dart'; +import 'providers/evaluation_provider.dart'; +import 'providers/theme_provider.dart'; +import 'services/notification_service.dart'; +import 'screens/login_screen.dart'; +import 'screens/home_screen.dart'; +import 'widgets/loading_indicator.dart'; +import 'utils/error_handler.dart'; +import 'utils/app_logger.dart'; + +void main() async { + // Run app with global error handling + await ErrorHandler.runAppWithErrorHandling(() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize logger + final logger = AppLogger(); + logger.initialize(); + logger.info('Application starting...'); + + // Initialize notification service + final notificationService = NotificationService(); + await notificationService.initialize(); + + runApp(MyApp(notificationService: notificationService)); + }); +} + +class MyApp extends StatelessWidget { + final NotificationService notificationService; + + const MyApp({super.key, required this.notificationService}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => ThemeProvider()), + ChangeNotifierProvider(create: (_) => AuthProvider()), + ChangeNotifierProxyProvider( + create: (context) { + // Create initial provider without connection + // Connection will be set later when user logs in + return EvaluationProvider( + service: null, + notificationService: notificationService, + ); + }, + update: (context, authProvider, previous) { + // Keep the same provider instance and just update connection + if (previous != null) { + previous.setConnection(authProvider.connection); + return previous; + } + return EvaluationProvider( + service: null, + notificationService: notificationService, + ); + }, + ), + ], + child: Consumer( + builder: (context, themeProvider, child) { + // Use MiSans font on Windows platform + final fontFamily = Platform.isWindows ? 'MiSans' : null; + + return MaterialApp( + title: '自动评教系统', + debugShowCheckedModeBanner: false, + theme: themeProvider.lightTheme.copyWith( + textTheme: themeProvider.lightTheme.textTheme.apply( + fontFamily: fontFamily, + ), + ), + darkTheme: themeProvider.darkTheme.copyWith( + textTheme: themeProvider.darkTheme.textTheme.apply( + fontFamily: fontFamily, + ), + ), + themeMode: themeProvider.themeMode, + home: const AppInitializer(), + ); + }, + ), + ); + } +} + +/// App initializer widget +/// +/// Handles initial app setup and session restoration +class AppInitializer extends StatefulWidget { + const AppInitializer({super.key}); + + @override + State createState() => _AppInitializerState(); +} + +class _AppInitializerState extends State { + bool _isInitializing = true; + bool _hasSession = false; + + @override + void initState() { + super.initState(); + // Defer initialization until after the first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + _initialize(); + }); + } + + Future _initialize() async { + final authProvider = Provider.of(context, listen: false); + + // Try to restore session + final restored = await authProvider.restoreSession(); + + if (mounted) { + setState(() { + _isInitializing = false; + _hasSession = restored; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_isInitializing) { + return const Scaffold(body: LoadingIndicator(message: '正在初始化...')); + } + + return _hasSession ? const HomeScreen() : const LoginScreen(); + } +} diff --git a/lib/models/.gitkeep b/lib/models/.gitkeep new file mode 100644 index 0000000..c3cd166 --- /dev/null +++ b/lib/models/.gitkeep @@ -0,0 +1,2 @@ +# Models directory +# This directory contains data models for the application diff --git a/lib/models/concurrent_task.dart b/lib/models/concurrent_task.dart new file mode 100644 index 0000000..a8d1fe2 --- /dev/null +++ b/lib/models/concurrent_task.dart @@ -0,0 +1,111 @@ +import 'course.dart'; + +/// Concurrent evaluation task status +enum TaskStatus { + waiting, // 等待开始 + preparing, // 准备评教(访问页面、解析问卷) + countdown, // 倒计时等待 + submitting, // 提交中 + verifying, // 验证中 + completed, // 完成 + failed, // 失败 +} + +/// Concurrent evaluation task +class ConcurrentTask { + final int taskId; + final Course course; + TaskStatus status; + String? statusMessage; + int countdownRemaining; + int countdownTotal; + String? errorMessage; + DateTime? startTime; + DateTime? endTime; + + ConcurrentTask({ + required this.taskId, + required this.course, + this.status = TaskStatus.waiting, + this.statusMessage, + this.countdownRemaining = 0, + this.countdownTotal = 0, + this.errorMessage, + this.startTime, + this.endTime, + }); + + /// Get progress (0.0 to 1.0) + double get progress { + switch (status) { + case TaskStatus.waiting: + return 0.0; + case TaskStatus.preparing: + return 0.1; + case TaskStatus.countdown: + if (countdownTotal > 0) { + return 0.1 + + 0.7 * (countdownTotal - countdownRemaining) / countdownTotal; + } + return 0.1; + case TaskStatus.submitting: + return 0.85; + case TaskStatus.verifying: + return 0.95; + case TaskStatus.completed: + case TaskStatus.failed: + return 1.0; + } + } + + /// Get status display text + String get statusText { + if (statusMessage != null) return statusMessage!; + + switch (status) { + case TaskStatus.waiting: + return '等待开始'; + case TaskStatus.preparing: + return '准备评教'; + case TaskStatus.countdown: + return '等待提交 (${countdownRemaining}s)'; + case TaskStatus.submitting: + return '提交中'; + case TaskStatus.verifying: + return '验证中'; + case TaskStatus.completed: + return '完成'; + case TaskStatus.failed: + return '失败'; + } + } + + /// Check if task is finished + bool get isFinished => + status == TaskStatus.completed || status == TaskStatus.failed; + + /// Check if task is successful + bool get isSuccess => status == TaskStatus.completed; + + ConcurrentTask copyWith({ + TaskStatus? status, + String? statusMessage, + int? countdownRemaining, + int? countdownTotal, + String? errorMessage, + DateTime? startTime, + DateTime? endTime, + }) { + return ConcurrentTask( + taskId: taskId, + course: course, + status: status ?? this.status, + statusMessage: statusMessage ?? this.statusMessage, + countdownRemaining: countdownRemaining ?? this.countdownRemaining, + countdownTotal: countdownTotal ?? this.countdownTotal, + errorMessage: errorMessage ?? this.errorMessage, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + ); + } +} diff --git a/lib/models/course.dart b/lib/models/course.dart new file mode 100644 index 0000000..ce03784 --- /dev/null +++ b/lib/models/course.dart @@ -0,0 +1,101 @@ +/// Course data model representing a course that needs evaluation +class Course { + final String id; + final String name; + final String teacher; + final String evaluatedPeople; + final String evaluatedPeopleNumber; + final String coureSequenceNumber; + final String evaluationContentNumber; + final String questionnaireCode; + final String questionnaireName; + final bool isEvaluated; + + Course({ + required this.id, + required this.name, + required this.teacher, + required this.evaluatedPeople, + required this.evaluatedPeopleNumber, + required this.coureSequenceNumber, + required this.evaluationContentNumber, + required this.questionnaireCode, + required this.questionnaireName, + this.isEvaluated = false, + }); + + /// Create Course from JSON + factory Course.fromJson(Map json) { + return Course( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + teacher: json['teacher'] as String? ?? '', + evaluatedPeople: json['evaluatedPeople'] as String? ?? '', + evaluatedPeopleNumber: json['evaluatedPeopleNumber'] as String? ?? '', + coureSequenceNumber: json['coureSequenceNumber'] as String? ?? '', + evaluationContentNumber: json['evaluationContentNumber'] as String? ?? '', + questionnaireCode: json['questionnaireCode'] as String? ?? '', + questionnaireName: json['questionnaireName'] as String? ?? '', + isEvaluated: json['isEvaluated'] as bool? ?? false, + ); + } + + /// Convert Course to JSON + Map toJson() { + return { + 'id': id, + 'name': name, + 'teacher': teacher, + 'evaluatedPeople': evaluatedPeople, + 'evaluatedPeopleNumber': evaluatedPeopleNumber, + 'coureSequenceNumber': coureSequenceNumber, + 'evaluationContentNumber': evaluationContentNumber, + 'questionnaireCode': questionnaireCode, + 'questionnaireName': questionnaireName, + 'isEvaluated': isEvaluated, + }; + } + + /// Create a copy of Course with updated fields + Course copyWith({ + String? id, + String? name, + String? teacher, + String? evaluatedPeople, + String? evaluatedPeopleNumber, + String? coureSequenceNumber, + String? evaluationContentNumber, + String? questionnaireCode, + String? questionnaireName, + bool? isEvaluated, + }) { + return Course( + id: id ?? this.id, + name: name ?? this.name, + teacher: teacher ?? this.teacher, + evaluatedPeople: evaluatedPeople ?? this.evaluatedPeople, + evaluatedPeopleNumber: + evaluatedPeopleNumber ?? this.evaluatedPeopleNumber, + coureSequenceNumber: coureSequenceNumber ?? this.coureSequenceNumber, + evaluationContentNumber: + evaluationContentNumber ?? this.evaluationContentNumber, + questionnaireCode: questionnaireCode ?? this.questionnaireCode, + questionnaireName: questionnaireName ?? this.questionnaireName, + isEvaluated: isEvaluated ?? this.isEvaluated, + ); + } + + @override + String toString() { + return 'Course(id: $id, name: $name, teacher: $teacher, isEvaluated: $isEvaluated)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Course && other.id == id; + } + + @override + int get hashCode => id.hashCode; +} diff --git a/lib/models/evaluation_history.dart b/lib/models/evaluation_history.dart new file mode 100644 index 0000000..27ab319 --- /dev/null +++ b/lib/models/evaluation_history.dart @@ -0,0 +1,54 @@ +import 'course.dart'; + +/// Evaluation history record +class EvaluationHistory { + final String id; + final Course course; + final DateTime timestamp; + final bool success; + final String? errorMessage; + + EvaluationHistory({ + required this.id, + required this.course, + required this.timestamp, + required this.success, + this.errorMessage, + }); + + factory EvaluationHistory.fromJson(Map json) { + return EvaluationHistory( + id: json['id'] as String? ?? '', + course: Course.fromJson(json['course'] as Map), + timestamp: json['timestamp'] != null + ? DateTime.parse(json['timestamp'] as String) + : DateTime.now(), + success: json['success'] as bool? ?? false, + errorMessage: json['errorMessage'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'course': course.toJson(), + 'timestamp': timestamp.toIso8601String(), + 'success': success, + 'errorMessage': errorMessage, + }; + } + + @override + String toString() { + return 'EvaluationHistory(id: $id, course: ${course.name}, success: $success, timestamp: $timestamp)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is EvaluationHistory && other.id == id; + } + + @override + int get hashCode => id.hashCode; +} diff --git a/lib/models/evaluation_result.dart b/lib/models/evaluation_result.dart new file mode 100644 index 0000000..91b1b83 --- /dev/null +++ b/lib/models/evaluation_result.dart @@ -0,0 +1,89 @@ +import 'course.dart'; + +/// Result of a single course evaluation +class EvaluationResult { + final Course course; + final bool success; + final String? errorMessage; + final DateTime timestamp; + + EvaluationResult({ + required this.course, + required this.success, + this.errorMessage, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); + + factory EvaluationResult.fromJson(Map json) { + return EvaluationResult( + course: Course.fromJson(json['course'] as Map), + success: json['success'] as bool? ?? false, + errorMessage: json['errorMessage'] as String?, + timestamp: json['timestamp'] != null + ? DateTime.parse(json['timestamp'] as String) + : DateTime.now(), + ); + } + + Map toJson() { + return { + 'course': course.toJson(), + 'success': success, + 'errorMessage': errorMessage, + 'timestamp': timestamp.toIso8601String(), + }; + } + + @override + String toString() { + return 'EvaluationResult(course: ${course.name}, success: $success, errorMessage: $errorMessage)'; + } +} + +/// Result of batch evaluation +class BatchEvaluationResult { + final int total; + final int success; + final int failed; + final List results; + final Duration duration; + + BatchEvaluationResult({ + required this.total, + required this.success, + required this.failed, + required this.results, + required this.duration, + }); + + factory BatchEvaluationResult.fromJson(Map json) { + return BatchEvaluationResult( + total: json['total'] as int? ?? 0, + success: json['success'] as int? ?? 0, + failed: json['failed'] as int? ?? 0, + results: + (json['results'] as List?) + ?.map((e) => EvaluationResult.fromJson(e as Map)) + .toList() ?? + [], + duration: Duration(milliseconds: json['durationMs'] as int? ?? 0), + ); + } + + Map toJson() { + return { + 'total': total, + 'success': success, + 'failed': failed, + 'results': results.map((e) => e.toJson()).toList(), + 'durationMs': duration.inMilliseconds, + }; + } + + double get successRate => total > 0 ? success / total : 0.0; + + @override + String toString() { + return 'BatchEvaluationResult(total: $total, success: $success, failed: $failed, duration: ${duration.inSeconds}s)'; + } +} diff --git a/lib/models/login_status.dart b/lib/models/login_status.dart new file mode 100644 index 0000000..9ae67ba --- /dev/null +++ b/lib/models/login_status.dart @@ -0,0 +1,85 @@ +/// EC登录状态 +class ECLoginStatus { + final bool success; + final bool failNotFoundTwfid; + final bool failNotFoundRsaKey; + final bool failNotFoundRsaExp; + final bool failNotFoundCsrfCode; + final bool failInvalidCredentials; + final bool failMaybeAttacked; + final bool failNetworkError; + final bool failUnknownError; + + ECLoginStatus({ + this.success = false, + this.failNotFoundTwfid = false, + this.failNotFoundRsaKey = false, + this.failNotFoundRsaExp = false, + this.failNotFoundCsrfCode = false, + this.failInvalidCredentials = false, + this.failMaybeAttacked = false, + this.failNetworkError = false, + this.failUnknownError = false, + }); + + bool get isSuccess => success; + bool get isFailed => !success; + + String get errorMessage { + if (failNotFoundTwfid) return '未找到TwfID'; + if (failNotFoundRsaKey) return '未找到RSA密钥'; + if (failNotFoundRsaExp) return '未找到RSA指数'; + if (failNotFoundCsrfCode) return '未找到CSRF代码'; + if (failInvalidCredentials) return '用户名或密码错误'; + if (failMaybeAttacked) return '可能受到攻击或需要验证码'; + if (failNetworkError) return '网络连接错误'; + if (failUnknownError) return '未知错误'; + return ''; + } +} + +/// UAAP登录状态 +class UAAPLoginStatus { + final bool success; + final bool failNotFoundLt; + final bool failNotFoundExecution; + final bool failInvalidCredentials; + final bool failNetworkError; + final bool failUnknownError; + + UAAPLoginStatus({ + this.success = false, + this.failNotFoundLt = false, + this.failNotFoundExecution = false, + this.failInvalidCredentials = false, + this.failNetworkError = false, + this.failUnknownError = false, + }); + + bool get isSuccess => success; + bool get isFailed => !success; + + String get errorMessage { + if (failNotFoundLt) return '未找到lt参数'; + if (failNotFoundExecution) return '未找到execution参数'; + if (failInvalidCredentials) return '用户名或密码错误'; + if (failNetworkError) return '网络连接错误'; + if (failUnknownError) return '未知错误'; + return ''; + } +} + +/// EC检查状态 +class ECCheckStatus { + final bool loggedIn; + final bool failNetworkError; + final bool failUnknownError; + + ECCheckStatus({ + this.loggedIn = false, + this.failNetworkError = false, + this.failUnknownError = false, + }); + + bool get isLoggedIn => loggedIn; +} diff --git a/lib/models/models.dart b/lib/models/models.dart new file mode 100644 index 0000000..966c76d --- /dev/null +++ b/lib/models/models.dart @@ -0,0 +1,6 @@ +/// Export all data models +export 'course.dart'; +export 'questionnaire.dart'; +export 'user_credentials.dart'; +export 'evaluation_result.dart'; +export 'evaluation_history.dart'; diff --git a/lib/models/questionnaire.dart b/lib/models/questionnaire.dart new file mode 100644 index 0000000..0cb972e --- /dev/null +++ b/lib/models/questionnaire.dart @@ -0,0 +1,224 @@ +/// Question type enum for text questions +enum QuestionType { + inspiration, // 启发类(包含"启发"关键词) + suggestion, // 建议类(包含"建议"、"意见"关键词) + overall, // 总体评价(zgpj) + general, // 通用类型 +} + +/// Metadata for questionnaire +class QuestionnaireMetadata { + final String title; + final String evaluatedPerson; + final String evaluationContent; + final String tokenValue; + final String questionnaireCode; + final String evaluatedPeopleNumber; + + QuestionnaireMetadata({ + required this.title, + required this.evaluatedPerson, + required this.evaluationContent, + required this.tokenValue, + required this.questionnaireCode, + required this.evaluatedPeopleNumber, + }); + + factory QuestionnaireMetadata.fromJson(Map json) { + return QuestionnaireMetadata( + title: json['title'] as String? ?? '', + evaluatedPerson: json['evaluatedPerson'] as String? ?? '', + evaluationContent: json['evaluationContent'] as String? ?? '', + tokenValue: json['tokenValue'] as String? ?? '', + questionnaireCode: json['questionnaireCode'] as String? ?? '', + evaluatedPeopleNumber: json['evaluatedPeopleNumber'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'title': title, + 'evaluatedPerson': evaluatedPerson, + 'evaluationContent': evaluationContent, + 'tokenValue': tokenValue, + 'questionnaireCode': questionnaireCode, + 'evaluatedPeopleNumber': evaluatedPeopleNumber, + }; + } +} + +/// Radio option for single-choice questions +class RadioOption { + final String label; // 如"(A) 非常满意" + final String value; // 如"5_1"(5分×100%) + final double score; // 解析后的分数 + final double weight; // 解析后的权重 + + RadioOption({ + required this.label, + required this.value, + required this.score, + required this.weight, + }); + + factory RadioOption.fromJson(Map json) { + return RadioOption( + label: json['label'] as String? ?? '', + value: json['value'] as String? ?? '', + score: (json['score'] as num?)?.toDouble() ?? 0.0, + weight: (json['weight'] as num?)?.toDouble() ?? 0.0, + ); + } + + Map toJson() { + return {'label': label, 'value': value, 'score': score, 'weight': weight}; + } + + @override + String toString() { + return 'RadioOption(label: $label, value: $value, score: $score, weight: $weight)'; + } +} + +/// Radio question (single-choice question) +class RadioQuestion { + final String key; // 动态key,如"0000000401" + final String questionText; // 题目文本 + final List options; + final String category; // 如"师德师风"、"教学内容" + + RadioQuestion({ + required this.key, + required this.questionText, + required this.options, + this.category = '', + }); + + factory RadioQuestion.fromJson(Map json) { + return RadioQuestion( + key: json['key'] as String? ?? '', + questionText: json['questionText'] as String? ?? '', + options: + (json['options'] as List?) + ?.map((e) => RadioOption.fromJson(e as Map)) + .toList() ?? + [], + category: json['category'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'key': key, + 'questionText': questionText, + 'options': options.map((e) => e.toJson()).toList(), + 'category': category, + }; + } + + @override + String toString() { + return 'RadioQuestion(key: $key, questionText: $questionText, category: $category, options: ${options.length})'; + } +} + +/// Text question (open-ended question) +class TextQuestion { + final String key; // 动态key或"zgpj" + final String questionText; // 题目文本 + final QuestionType type; // 通过关键词识别的类型 + final bool isRequired; // 是否必填 + + TextQuestion({ + required this.key, + required this.questionText, + required this.type, + this.isRequired = false, + }); + + factory TextQuestion.fromJson(Map json) { + return TextQuestion( + key: json['key'] as String? ?? '', + questionText: json['questionText'] as String? ?? '', + type: QuestionType.values.firstWhere( + (e) => e.toString() == 'QuestionType.${json['type']}', + orElse: () => QuestionType.general, + ), + isRequired: json['isRequired'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'key': key, + 'questionText': questionText, + 'type': type.toString().split('.').last, + 'isRequired': isRequired, + }; + } + + @override + String toString() { + return 'TextQuestion(key: $key, questionText: $questionText, type: $type, isRequired: $isRequired)'; + } +} + +/// Complete questionnaire structure +class Questionnaire { + final QuestionnaireMetadata metadata; + final List radioQuestions; + final List textQuestions; + final String tokenValue; + final String questionnaireCode; + final String evaluationContent; + final String evaluatedPeopleNumber; + + Questionnaire({ + required this.metadata, + required this.radioQuestions, + required this.textQuestions, + required this.tokenValue, + required this.questionnaireCode, + required this.evaluationContent, + required this.evaluatedPeopleNumber, + }); + + factory Questionnaire.fromJson(Map json) { + return Questionnaire( + metadata: QuestionnaireMetadata.fromJson( + json['metadata'] as Map? ?? {}, + ), + radioQuestions: + (json['radioQuestions'] as List?) + ?.map((e) => RadioQuestion.fromJson(e as Map)) + .toList() ?? + [], + textQuestions: + (json['textQuestions'] as List?) + ?.map((e) => TextQuestion.fromJson(e as Map)) + .toList() ?? + [], + tokenValue: json['tokenValue'] as String? ?? '', + questionnaireCode: json['questionnaireCode'] as String? ?? '', + evaluationContent: json['evaluationContent'] as String? ?? '', + evaluatedPeopleNumber: json['evaluatedPeopleNumber'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'metadata': metadata.toJson(), + 'radioQuestions': radioQuestions.map((e) => e.toJson()).toList(), + 'textQuestions': textQuestions.map((e) => e.toJson()).toList(), + 'tokenValue': tokenValue, + 'questionnaireCode': questionnaireCode, + 'evaluationContent': evaluationContent, + 'evaluatedPeopleNumber': evaluatedPeopleNumber, + }; + } + + @override + String toString() { + return 'Questionnaire(radioQuestions: ${radioQuestions.length}, textQuestions: ${textQuestions.length})'; + } +} diff --git a/lib/models/user_credentials.dart b/lib/models/user_credentials.dart new file mode 100644 index 0000000..2b8ad69 --- /dev/null +++ b/lib/models/user_credentials.dart @@ -0,0 +1,78 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// User credentials for authentication +class UserCredentials { + final String userId; + final String ecPassword; + final String password; + + UserCredentials({ + required this.userId, + required this.ecPassword, + required this.password, + }); + + factory UserCredentials.fromJson(Map json) { + return UserCredentials( + userId: json['userId'] as String? ?? '', + ecPassword: json['ecPassword'] as String? ?? '', + password: json['password'] as String? ?? '', + ); + } + + Map toJson() { + return {'userId': userId, 'ecPassword': ecPassword, 'password': password}; + } + + /// Save credentials securely using flutter_secure_storage + Future saveSecurely() async { + const storage = FlutterSecureStorage(); + await storage.write(key: 'user_id', value: userId); + await storage.write(key: 'ec_password', value: ecPassword); + await storage.write(key: 'password', value: password); + } + + /// Load credentials from secure storage + static Future loadSecurely() async { + const storage = FlutterSecureStorage(); + + final userId = await storage.read(key: 'user_id'); + final ecPassword = await storage.read(key: 'ec_password'); + final password = await storage.read(key: 'password'); + + if (userId == null || ecPassword == null || password == null) { + return null; + } + + return UserCredentials( + userId: userId, + ecPassword: ecPassword, + password: password, + ); + } + + /// Clear credentials from secure storage + static Future clearSecurely() async { + const storage = FlutterSecureStorage(); + await storage.delete(key: 'user_id'); + await storage.delete(key: 'ec_password'); + await storage.delete(key: 'password'); + } + + @override + String toString() { + return 'UserCredentials(userId: $userId)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is UserCredentials && + other.userId == userId && + other.ecPassword == ecPassword && + other.password == password; + } + + @override + int get hashCode => Object.hash(userId, ecPassword, password); +} diff --git a/lib/providers/.gitkeep b/lib/providers/.gitkeep new file mode 100644 index 0000000..dc8689e --- /dev/null +++ b/lib/providers/.gitkeep @@ -0,0 +1,2 @@ +# Providers directory +# This directory contains state management providers diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart new file mode 100644 index 0000000..f8cb9ae --- /dev/null +++ b/lib/providers/auth_provider.dart @@ -0,0 +1,273 @@ +import 'package:flutter/foundation.dart'; +import '../models/user_credentials.dart'; +import '../services/aufe_connection.dart'; + +/// Authentication state enum +enum AuthState { initial, loading, authenticated, unauthenticated, error } + +/// Provider for managing authentication state and user sessions +/// +/// Handles login, logout, session checking, and credential management +/// Uses ChangeNotifier to notify listeners of state changes +/// +/// Usage example: +/// ```dart +/// final authProvider = Provider.of(context); +/// +/// // Login +/// await authProvider.login( +/// userId: '学号', +/// ecPassword: 'EC密码', +/// password: 'UAAP密码', +/// ); +/// +/// // Check session +/// final isValid = await authProvider.checkSession(); +/// +/// // Logout +/// await authProvider.logout(); +/// ``` +class AuthProvider extends ChangeNotifier { + AUFEConnection? _connection; + AuthState _state = AuthState.initial; + String? _errorMessage; + UserCredentials? _credentials; + + /// Get current authentication state + AuthState get state => _state; + + /// Get current error message (if any) + String? get errorMessage => _errorMessage; + + /// Get current connection instance + AUFEConnection? get connection => _connection; + + /// Get current user credentials + UserCredentials? get credentials => _credentials; + + /// Check if user is authenticated + bool get isAuthenticated => _state == AuthState.authenticated; + + /// Login with user credentials + /// + /// Creates AUFEConnection and performs both EC and UAAP login + /// Saves credentials securely on successful login + /// + /// [userId] - Student ID + /// [ecPassword] - EC system password + /// [password] - UAAP system password + /// + /// Returns true if login succeeds, false otherwise + Future login({ + required String userId, + required String ecPassword, + required String password, + }) async { + try { + print('🔐 Starting login process...'); + print('🔐 User ID: $userId'); + + _setState(AuthState.loading); + _errorMessage = null; + + // Create credentials + final credentials = UserCredentials( + userId: userId, + ecPassword: ecPassword, + password: password, + ); + + // Create connection + print('🔐 Creating AUFEConnection...'); + final connection = AUFEConnection( + userId: userId, + ecPassword: ecPassword, + password: password, + ); + + // Initialize HTTP client + print('🔐 Starting HTTP client...'); + connection.startClient(); + + // Perform EC login + print('🔐 Performing EC login...'); + final ecLoginStatus = await connection.ecLogin(); + print('🔐 EC login result: ${ecLoginStatus.success}'); + + if (!ecLoginStatus.success) { + _errorMessage = _getEcLoginErrorMessage(ecLoginStatus); + print('❌ EC login failed: $_errorMessage'); + _setState(AuthState.error); + await connection.close(); + return false; + } + + // Perform UAAP login + print('🔐 Performing UAAP login...'); + final uaapLoginStatus = await connection.uaapLogin(); + print('🔐 UAAP login result: ${uaapLoginStatus.success}'); + + if (!uaapLoginStatus.success) { + _errorMessage = _getUaapLoginErrorMessage(uaapLoginStatus); + print('❌ UAAP login failed: $_errorMessage'); + _setState(AuthState.error); + await connection.close(); + return false; + } + + // Save credentials securely + print('🔐 Saving credentials...'); + await credentials.saveSecurely(); + + // Update state + _connection = connection; + _credentials = credentials; + _setState(AuthState.authenticated); + + print('✅ Login successful!'); + return true; + } catch (e, stackTrace) { + _errorMessage = '登录过程出错: $e'; + print('❌ Login error: $e'); + print('❌ Stack trace: $stackTrace'); + _setState(AuthState.error); + return false; + } + } + + /// Logout and clear all session data + /// + /// Closes connection, clears credentials from secure storage, + /// and resets authentication state + Future logout() async { + try { + // Close connection + if (_connection != null) { + await _connection!.close(); + _connection = null; + } + + // Clear credentials from secure storage + await UserCredentials.clearSecurely(); + + // Reset state + _credentials = null; + _errorMessage = null; + _setState(AuthState.unauthenticated); + } catch (e) { + debugPrint('Error during logout: $e'); + // Still reset state even if cleanup fails + _connection = null; + _credentials = null; + _setState(AuthState.unauthenticated); + } + } + + /// Check if current session is still valid + /// + /// Performs health check on the connection + /// If session is invalid, updates state to unauthenticated + /// + /// Returns true if session is valid, false otherwise + Future checkSession() async { + if (_connection == null || _state != AuthState.authenticated) { + _setState(AuthState.unauthenticated); + return false; + } + + try { + final isHealthy = await _connection!.healthCheck(); + + if (!isHealthy) { + _errorMessage = '会话已过期,请重新登录'; + _setState(AuthState.unauthenticated); + return false; + } + + return true; + } catch (e) { + _errorMessage = '检查会话状态失败: $e'; + _setState(AuthState.error); + return false; + } + } + + /// Attempt to restore session from saved credentials + /// + /// Loads credentials from secure storage and attempts to login + /// Useful for auto-login on app startup + /// + /// Returns true if session restored successfully, false otherwise + Future restoreSession() async { + try { + _setState(AuthState.loading); + + // Load saved credentials + final credentials = await UserCredentials.loadSecurely(); + if (credentials == null) { + _setState(AuthState.unauthenticated); + return false; + } + + // Attempt login with saved credentials + return await login( + userId: credentials.userId, + ecPassword: credentials.ecPassword, + password: credentials.password, + ); + } catch (e) { + _errorMessage = '恢复会话失败: $e'; + _setState(AuthState.unauthenticated); + return false; + } + } + + /// Update authentication state and notify listeners + void _setState(AuthState newState) { + _state = newState; + notifyListeners(); + } + + /// Get user-friendly error message for EC login status + String _getEcLoginErrorMessage(dynamic status) { + if (status.failInvalidCredentials) { + return 'EC系统用户名或密码错误'; + } else if (status.failNotFoundTwfid) { + return '无法获取TwfID,请稍后重试'; + } else if (status.failNotFoundRsaKey) { + return '无法获取RSA密钥,请稍后重试'; + } else if (status.failNotFoundRsaExp) { + return '无法获取RSA指数,请稍后重试'; + } else if (status.failNotFoundCsrfCode) { + return '无法获取CSRF代码,请稍后重试'; + } else if (status.failMaybeAttacked) { + return '登录频繁,请稍后重试'; + } else if (status.failNetworkError) { + return 'EC系统网络连接失败'; + } else { + return 'EC系统登录失败'; + } + } + + /// Get user-friendly error message for UAAP login status + String _getUaapLoginErrorMessage(dynamic status) { + if (status.failInvalidCredentials) { + return 'UAAP系统用户名或密码错误'; + } else if (status.failNotFoundLt) { + return '无法获取lt参数,请稍后重试'; + } else if (status.failNotFoundExecution) { + return '无法获取execution参数,请稍后重试'; + } else if (status.failNetworkError) { + return 'UAAP系统网络连接失败'; + } else { + return 'UAAP系统登录失败'; + } + } + + @override + void dispose() { + // Close connection when provider is disposed + _connection?.close(); + super.dispose(); + } +} diff --git a/lib/providers/evaluation_provider.dart b/lib/providers/evaluation_provider.dart new file mode 100644 index 0000000..4e08ff5 --- /dev/null +++ b/lib/providers/evaluation_provider.dart @@ -0,0 +1,935 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import '../models/course.dart'; +import '../models/evaluation_result.dart'; +import '../models/evaluation_history.dart'; +import '../models/concurrent_task.dart'; +import '../services/evaluation_service.dart'; +import '../services/notification_service.dart'; +import '../services/storage_service.dart'; +import '../services/questionnaire_parser.dart'; +import '../utils/text_generator.dart'; + +/// Evaluation state enum +enum EvaluationState { idle, loading, evaluating, completed, error } + +/// Provider for managing course evaluation state and operations +/// +/// Handles loading courses, batch evaluation, progress tracking, +/// and notification management +/// +/// Usage example: +/// ```dart +/// final evaluationProvider = Provider.of(context); +/// +/// // Load courses +/// await evaluationProvider.loadCourses(); +/// +/// // Start batch evaluation +/// await evaluationProvider.startBatchEvaluation(); +/// +/// // Retry failed courses +/// await evaluationProvider.retryFailed(); +/// ``` +class EvaluationProvider extends ChangeNotifier { + EvaluationService? _service; + final NotificationService _notificationService; + final StorageService _storageService; + + List _courses = []; + EvaluationState _state = EvaluationState.idle; + BatchEvaluationResult? _lastResult; + String? _errorMessage; + List _evaluationHistory = []; + + // Progress tracking + int _currentProgress = 0; + int _totalProgress = 0; + Course? _currentCourse; + String? _currentStatus; + final List _logs = []; + + // Countdown tracking + int _countdownRemaining = 0; + int _countdownTotal = 0; + + // Concurrent evaluation tracking + final List _concurrentTasks = []; + bool _isConcurrentMode = false; + + EvaluationProvider({ + EvaluationService? service, + required NotificationService notificationService, + StorageService? storageService, + }) : _service = service, + _notificationService = notificationService, + _storageService = storageService ?? StorageService() { + _loadEvaluationHistory(); + } + + dynamic _connection; + + /// Set the connection to use for fetching courses and create evaluation service + void setConnection(dynamic connection) { + _connection = connection; + if (connection != null) { + _service = EvaluationService( + connection: connection, + parser: QuestionnaireParser(), + textGenerator: TextGenerator(), + ); + } + } + + /// Get current evaluation state + EvaluationState get state => _state; + + /// Get list of courses + List get courses => _courses; + + /// Get last batch evaluation result + BatchEvaluationResult? get lastResult => _lastResult; + + /// Get current error message (if any) + String? get errorMessage => _errorMessage; + + /// Get current progress (0.0 to 1.0) + double get progress => + _totalProgress > 0 ? _currentProgress / _totalProgress : 0.0; + + /// Get current progress count + int get currentProgress => _currentProgress; + + /// Get total progress count + int get totalProgress => _totalProgress; + + /// Get current course being evaluated + Course? get currentCourse => _currentCourse; + + /// Get current status message + String? get currentStatus => _currentStatus; + + /// Get evaluation logs + List get logs => List.unmodifiable(_logs); + + /// Get countdown remaining seconds + int get countdownRemaining => _countdownRemaining; + + /// Get countdown total seconds + int get countdownTotal => _countdownTotal; + + /// Get countdown progress (0.0 to 1.0) + double get countdownProgress => _countdownTotal > 0 + ? (_countdownTotal - _countdownRemaining) / _countdownTotal + : 0.0; + + /// Get concurrent tasks + List get concurrentTasks => + List.unmodifiable(_concurrentTasks); + + /// Check if in concurrent mode + bool get isConcurrentMode => _isConcurrentMode; + + /// Add a log entry + void _addLog(String message, {bool updateLast = false}) { + final timestamp = DateTime.now().toString().substring(11, 19); + final logEntry = '[$timestamp] $message'; + + if (updateLast && _logs.isNotEmpty) { + // Update the last log entry + _logs[_logs.length - 1] = logEntry; + } else { + // Add new log entry + _logs.add(logEntry); + // Keep only last 100 logs + if (_logs.length > 100) { + _logs.removeAt(0); + } + } + notifyListeners(); + } + + /// Clear logs + void clearLogs() { + _logs.clear(); + notifyListeners(); + } + + /// Get pending courses (not yet evaluated) + List get pendingCourses => + _courses.where((c) => !c.isEvaluated).toList(); + + /// Get evaluated courses + List get evaluatedCourses => + _courses.where((c) => c.isEvaluated).toList(); + + /// Get failed courses from last batch evaluation + List get failedCourses { + if (_lastResult == null) return []; + return _lastResult!.results + .where((r) => !r.success) + .map((r) => r.course) + .toList(); + } + + /// Get evaluation history + List get evaluationHistory => _evaluationHistory; + + /// Load evaluation history from storage + Future _loadEvaluationHistory() async { + try { + _evaluationHistory = await _storageService.loadEvaluationHistory(); + notifyListeners(); + } catch (e) { + debugPrint('Failed to load evaluation history: $e'); + } + } + + /// Refresh evaluation history from storage + Future refreshEvaluationHistory() async { + await _loadEvaluationHistory(); + } + + /// Clear evaluation history + Future clearEvaluationHistory() async { + try { + await _storageService.clearEvaluationHistory(); + _evaluationHistory = []; + notifyListeners(); + } catch (e) { + debugPrint('Failed to clear evaluation history: $e'); + } + } + + /// Save evaluation results to history + Future _saveEvaluationResults(List results) async { + try { + final histories = results.map((result) { + return EvaluationHistory( + id: '${result.course.id}_${result.timestamp.millisecondsSinceEpoch}', + course: result.course, + timestamp: result.timestamp, + success: result.success, + errorMessage: result.errorMessage, + ); + }).toList(); + + await _storageService.saveEvaluationHistories(histories); + await _loadEvaluationHistory(); + } catch (e) { + debugPrint('Failed to save evaluation results: $e'); + } + } + + /// Load courses from the server + /// + /// Fetches the list of courses that need evaluation + /// Updates state and notifies listeners + /// + /// Returns true if successful, false otherwise + Future loadCourses() async { + print('📚 loadCourses called'); + print('📚 _connection: $_connection'); + + if (_connection == null) { + _errorMessage = '未设置连接'; + _setState(EvaluationState.error); + print('❌ No connection set'); + return false; + } + + try { + print('📚 Setting state to loading...'); + _setState(EvaluationState.loading); + _errorMessage = null; + + print('📚 Calling _connection.fetchCourseList()...'); + final courses = await _connection.fetchCourseList(); + print('📚 Received ${courses.length} courses'); + + _courses = courses; + _setState(EvaluationState.idle); + + return true; + } catch (e, stackTrace) { + _errorMessage = '加载课程列表失败: $e'; + _setState(EvaluationState.error); + print('❌ Error loading courses: $e'); + print('❌ Stack trace: $stackTrace'); + return false; + } + } + + /// Start batch evaluation of all pending courses + /// + /// Evaluates all courses that haven't been evaluated yet + /// Shows notifications for progress and completion + /// Updates state and progress in real-time + /// + /// Returns the batch evaluation result + Future startBatchEvaluation() async { + if (_service == null) { + _errorMessage = '评教服务未初始化'; + _setState(EvaluationState.error); + return null; + } + + try { + _setState(EvaluationState.evaluating); + _errorMessage = null; + + // Get pending courses + final pending = pendingCourses; + if (pending.isEmpty) { + _errorMessage = '没有待评课程'; + _setState(EvaluationState.idle); + return null; + } + + // Initialize progress + _currentProgress = 0; + _totalProgress = pending.length; + _currentCourse = null; + _currentStatus = null; + notifyListeners(); + + // Show start notification + await _notificationService.showBatchStartNotification(_totalProgress); + + // Clear previous logs + _logs.clear(); + _addLog('开始批量评教,共 $_totalProgress 门课程'); + + // Start batch evaluation with custom logic + final results = []; + final startTime = DateTime.now(); + + for (int i = 0; i < pending.length; i++) { + final course = pending[i]; + + _currentProgress = i; + _currentCourse = course; + _currentStatus = '准备评教'; + notifyListeners(); + + _addLog( + '❤ Created By LoveACE Team, 🌧 Powered By Sibuxiangx & Flutter', + ); + _addLog('开始评教: ${course.name} (${course.teacher})'); + + // 1. Prepare evaluation + _currentStatus = '访问评价页面'; + notifyListeners(); + _addLog('📝 ${course.name}: 访问评价页面'); + + final formData = await _service!.prepareEvaluation( + course, + totalCourses: _totalProgress, + ); + + if (formData == null) { + final result = EvaluationResult( + course: course, + success: false, + errorMessage: '无法访问评价页面', + ); + results.add(result); + _addLog('❌ ${course.name}: 访问失败,任务中断'); + + // Stop on error + _currentProgress = i + 1; + break; + } + + _currentStatus = '解析问卷'; + _addLog('📝 ${course.name}: 解析问卷'); + + _currentStatus = '生成答案'; + _addLog('📝 ${course.name}: 生成答案'); + + // 2. Countdown (140 seconds) + _currentStatus = '等待提交'; + _countdownTotal = 140; + + for (int countdown = 140; countdown > 0; countdown--) { + _countdownRemaining = countdown; + notifyListeners(); + await Future.delayed(const Duration(seconds: 1)); + } + + _countdownRemaining = 0; + _countdownTotal = 0; + + // 3. Submit evaluation + _currentStatus = '提交评价'; + notifyListeners(); + _addLog('📝 ${course.name}: 提交评价'); + + final result = await _service!.submitEvaluation(course, formData); + results.add(result); + + if (!result.success) { + _addLog('❌ ${course.name}: 评教失败,任务中断'); + _currentProgress = i + 1; + break; + } + + // 4. Verify evaluation + _currentStatus = '验证结果'; + _addLog('📝 ${course.name}: 验证结果'); + + final updatedCourses = await _connection!.fetchCourseList(); + final updatedCourse = updatedCourses.firstWhere( + (c) => c.id == course.id, + orElse: () => course, + ); + + if (!updatedCourse.isEvaluated) { + results[results.length - 1] = EvaluationResult( + course: course, + success: false, + errorMessage: '评教未生效,服务器未确认', + ); + _addLog('❌ ${course.name}: 评教未生效,任务中断'); + _currentProgress = i + 1; + break; + } + + _addLog('✅ ${course.name}: 评教完成'); + _currentProgress = i + 1; + _currentStatus = '评教完成'; + notifyListeners(); + + // Small delay between courses + if (i < pending.length - 1) { + await Future.delayed(const Duration(milliseconds: 500)); + } + } + + // Calculate statistics + final successCount = results.where((r) => r.success).length; + final failedCount = results.where((r) => !r.success).length; + final duration = DateTime.now().difference(startTime); + + final result = BatchEvaluationResult( + total: results.length, + success: successCount, + failed: failedCount, + results: results, + duration: duration, + ); + + // Save result + _lastResult = result; + + // Save evaluation results to history + await _saveEvaluationResults(result.results); + + // Add completion log + if (result.failed > 0) { + _addLog( + '❌ 批量评教中断: 成功 ${result.success}/${result.total},失败 ${result.failed}', + ); + } else { + _addLog('✅ 批量评教完成: 全部 ${result.total} 门课程评教成功'); + } + + // Show completion notification + await _notificationService.showCompletionNotification( + success: result.success, + failed: result.failed, + total: result.total, + ); + + // Reload courses to update evaluation status + _addLog('刷新课程列表...'); + await loadCourses(); + _addLog('课程列表已更新'); + + _setState(EvaluationState.completed); + return result; + } catch (e) { + _errorMessage = '批量评教失败: $e'; + _setState(EvaluationState.error); + + // Show error notification + await _notificationService.showErrorNotification(_errorMessage!); + + return null; + } + } + + /// Retry evaluation for failed courses + /// + /// Re-evaluates only the courses that failed in the last batch evaluation + /// Useful for handling temporary network issues or server errors + /// + /// Returns the batch evaluation result for retry attempt + Future retryFailed() async { + if (_service == null) { + _errorMessage = '评教服务未初始化'; + _setState(EvaluationState.error); + return null; + } + + if (_lastResult == null || failedCourses.isEmpty) { + _errorMessage = '没有失败的课程需要重试'; + return null; + } + + try { + _setState(EvaluationState.evaluating); + _errorMessage = null; + + final failedList = failedCourses; + + // Initialize progress + _currentProgress = 0; + _totalProgress = failedList.length; + _currentCourse = null; + _currentStatus = null; + notifyListeners(); + + // Show start notification + await _notificationService.showBatchStartNotification(_totalProgress); + + // Evaluate each failed course + final results = []; + final startTime = DateTime.now(); + + for (int i = 0; i < failedList.length; i++) { + final course = failedList[i]; + + // Update progress + _currentProgress = i + 1; + _currentCourse = course; + _currentStatus = '正在重试...'; + notifyListeners(); + + // Update notification + await _notificationService.updateProgressNotification( + current: i + 1, + total: _totalProgress, + courseName: course.name, + ); + + // Evaluate course + final result = await _service!.evaluateCourse( + course, + onStatusChange: (status) { + _currentStatus = status; + notifyListeners(); + }, + ); + results.add(result); + + // Small delay between evaluations + if (i < failedList.length - 1) { + await Future.delayed(const Duration(milliseconds: 500)); + } + } + + // Calculate statistics + final successCount = results.where((r) => r.success).length; + final failedCount = results.where((r) => !r.success).length; + final duration = DateTime.now().difference(startTime); + + final retryResult = BatchEvaluationResult( + total: failedList.length, + success: successCount, + failed: failedCount, + results: results, + duration: duration, + ); + + // Update last result + _lastResult = retryResult; + + // Save retry results to history + await _saveEvaluationResults(results); + + // Show completion notification + await _notificationService.showCompletionNotification( + success: successCount, + failed: failedCount, + total: failedList.length, + ); + + // Reload courses + await loadCourses(); + + _setState(EvaluationState.completed); + return retryResult; + } catch (e) { + _errorMessage = '重试失败: $e'; + _setState(EvaluationState.error); + + // Show error notification + await _notificationService.showErrorNotification(_errorMessage!); + + return null; + } + } + + /// Evaluate a single course + /// + /// [course] - The course to evaluate + /// + /// Returns the evaluation result + Future evaluateSingleCourse(Course course) async { + if (_service == null) { + _errorMessage = '评教服务未初始化'; + _setState(EvaluationState.error); + return null; + } + + try { + _setState(EvaluationState.evaluating); + _errorMessage = null; + + _currentProgress = 0; + _totalProgress = 1; + _currentCourse = course; + _currentStatus = '正在评教...'; + notifyListeners(); + + final result = await _service!.evaluateCourse( + course, + onStatusChange: (status) { + _currentStatus = status; + notifyListeners(); + }, + ); + + // Save single evaluation result to history + await _saveEvaluationResults([result]); + + if (result.success) { + // Reload courses to update status + await loadCourses(); + } + + _setState(EvaluationState.completed); + return result; + } catch (e) { + _errorMessage = '评教失败: $e'; + _setState(EvaluationState.error); + return null; + } + } + + /// Clear last evaluation result + void clearLastResult() { + _lastResult = null; + _currentProgress = 0; + _totalProgress = 0; + _currentCourse = null; + _currentStatus = null; + notifyListeners(); + } + + /// Reset provider state + void reset() { + _courses = []; + _state = EvaluationState.idle; + _lastResult = null; + _errorMessage = null; + _currentProgress = 0; + _totalProgress = 0; + _currentCourse = null; + _currentStatus = null; + _logs.clear(); + notifyListeners(); + } + + /// Update evaluation state and notify listeners + void _setState(EvaluationState newState) { + _state = newState; + notifyListeners(); + } + + /// Start concurrent batch evaluation + /// + /// Evaluates all pending courses concurrently with 6-second intervals + /// Each task runs independently with its own 140-second countdown + /// + /// Returns the batch evaluation result + Future startConcurrentBatchEvaluation() async { + if (_service == null) { + _errorMessage = '评教服务未初始化'; + _setState(EvaluationState.error); + return null; + } + + try { + _setState(EvaluationState.evaluating); + _errorMessage = null; + _isConcurrentMode = true; + + // Get pending courses + final pending = pendingCourses; + if (pending.isEmpty) { + _errorMessage = '没有待评课程'; + _setState(EvaluationState.idle); + _isConcurrentMode = false; + return null; + } + + // Initialize + _currentProgress = 0; + _totalProgress = pending.length; + _concurrentTasks.clear(); + _logs.clear(); + notifyListeners(); + + // Show start notification + await _notificationService.showBatchStartNotification(_totalProgress); + _addLog('开始并发批量评教,共 $_totalProgress 门课程'); + + // Create tasks + for (int i = 0; i < pending.length; i++) { + _concurrentTasks.add( + ConcurrentTask( + taskId: i + 1, + course: pending[i], + status: TaskStatus.waiting, + ), + ); + } + notifyListeners(); + + // Start tasks with 6-second intervals + final taskFutures = >[]; + final startTime = DateTime.now(); + + for (int i = 0; i < _concurrentTasks.length; i++) { + final task = _concurrentTasks[i]; + + // Wait 6 seconds before starting next task (except first one) + if (i > 0) { + _addLog('等待 6 秒后启动下一个任务... (${i + 1}/$_totalProgress)'); + await Future.delayed(const Duration(seconds: 6)); + } + + // Start task + _addLog('启动任务 ${task.taskId}: ${task.course.name}'); + taskFutures.add(_executeConcurrentTask(task)); + } + + // Wait for all tasks to complete + _addLog('所有任务已启动,等待完成...'); + final results = await Future.wait(taskFutures); + + // Calculate statistics + final successCount = results.where((r) => r.success).length; + final failedCount = results.where((r) => !r.success).length; + final duration = DateTime.now().difference(startTime); + + final result = BatchEvaluationResult( + total: results.length, + success: successCount, + failed: failedCount, + results: results, + duration: duration, + ); + + // Save result + _lastResult = result; + _currentProgress = _totalProgress; + + // Save evaluation results to history + await _saveEvaluationResults(result.results); + + // Add completion log + if (result.failed > 0) { + _addLog( + '⚠️ 并发评教完成: 成功 ${result.success}/${result.total},失败 ${result.failed}', + ); + } else { + _addLog('✅ 并发评教完成: 全部 ${result.total} 门课程评教成功'); + } + + // Show completion notification + await _notificationService.showCompletionNotification( + success: result.success, + failed: result.failed, + total: result.total, + ); + + // Reload courses to update evaluation status + _addLog('刷新课程列表...'); + await loadCourses(); + _addLog('课程列表已更新'); + + _setState(EvaluationState.completed); + _isConcurrentMode = false; + return result; + } catch (e) { + _errorMessage = '并发评教失败: $e'; + _setState(EvaluationState.error); + _isConcurrentMode = false; + + // Show error notification + await _notificationService.showErrorNotification(_errorMessage!); + + return null; + } + } + + /// Execute a single concurrent task + Future _executeConcurrentTask(ConcurrentTask task) async { + try { + // Update task status + _updateTaskStatus( + task.taskId, + TaskStatus.preparing, + '❤ Created By LoveACE Team, 🌧 Powered By Sibuxiangx & Flutter', + ); + _addLog('任务 ${task.taskId} [${task.course.name}]: 开始评教'); + + task.startTime = DateTime.now(); + + // 1. Prepare evaluation + _updateTaskStatus(task.taskId, TaskStatus.preparing, '访问评价页面'); + _addLog('任务 ${task.taskId} [${task.course.name}]: 访问评价页面'); + + final formData = await _service!.prepareEvaluation( + task.course, + totalCourses: _totalProgress, + ); + + if (formData == null) { + _updateTaskStatus( + task.taskId, + TaskStatus.failed, + '访问失败', + errorMessage: '无法访问评价页面', + ); + _addLog('任务 ${task.taskId} [${task.course.name}]: ❌ 访问失败'); + return EvaluationResult( + course: task.course, + success: false, + errorMessage: '无法访问评价页面', + ); + } + + _updateTaskStatus(task.taskId, TaskStatus.preparing, '解析问卷'); + _addLog('任务 ${task.taskId} [${task.course.name}]: 解析问卷'); + + _updateTaskStatus(task.taskId, TaskStatus.preparing, '生成答案'); + _addLog('任务 ${task.taskId} [${task.course.name}]: 生成答案'); + + // 2. Countdown (140 seconds) - independent for each task + _updateTaskStatus( + task.taskId, + TaskStatus.countdown, + '等待提交', + countdownTotal: 140, + ); + _addLog('任务 ${task.taskId} [${task.course.name}]: 开始独立等待 140 秒'); + + for (int countdown = 140; countdown > 0; countdown--) { + _updateTaskCountdown(task.taskId, countdown, 140); + await Future.delayed(const Duration(seconds: 1)); + } + + _addLog('任务 ${task.taskId} [${task.course.name}]: 等待完成'); + + // 3. Submit evaluation + _updateTaskStatus(task.taskId, TaskStatus.submitting, '提交评价'); + _addLog('任务 ${task.taskId} [${task.course.name}]: 提交评价'); + + final result = await _service!.submitEvaluation(task.course, formData); + + if (!result.success) { + _updateTaskStatus( + task.taskId, + TaskStatus.failed, + '提交失败', + errorMessage: result.errorMessage, + ); + _addLog('任务 ${task.taskId} [${task.course.name}]: ❌ 提交失败'); + return result; + } + + // 4. Verify evaluation + _updateTaskStatus(task.taskId, TaskStatus.verifying, '验证结果'); + _addLog('任务 ${task.taskId} [${task.course.name}]: 验证结果'); + + final updatedCourses = await _connection!.fetchCourseList(); + final updatedCourse = updatedCourses.firstWhere( + (c) => c.id == task.course.id, + orElse: () => task.course, + ); + + if (!updatedCourse.isEvaluated) { + _updateTaskStatus( + task.taskId, + TaskStatus.failed, + '验证失败', + errorMessage: '评教未生效,服务器未确认', + ); + _addLog('任务 ${task.taskId} [${task.course.name}]: ❌ 评教未生效'); + return EvaluationResult( + course: task.course, + success: false, + errorMessage: '评教未生效,服务器未确认', + ); + } + + // Success + _updateTaskStatus(task.taskId, TaskStatus.completed, '完成'); + _addLog('任务 ${task.taskId} [${task.course.name}]: ✅ 评教完成'); + _currentProgress++; + notifyListeners(); + + task.endTime = DateTime.now(); + return EvaluationResult(course: task.course, success: true); + } catch (e) { + _updateTaskStatus( + task.taskId, + TaskStatus.failed, + '异常', + errorMessage: e.toString(), + ); + _addLog('任务 ${task.taskId} [${task.course.name}]: ❌ 异常: $e'); + return EvaluationResult( + course: task.course, + success: false, + errorMessage: '评教过程出错: $e', + ); + } + } + + /// Update task status + void _updateTaskStatus( + int taskId, + TaskStatus status, + String? statusMessage, { + String? errorMessage, + int? countdownTotal, + }) { + final index = _concurrentTasks.indexWhere((t) => t.taskId == taskId); + if (index != -1) { + _concurrentTasks[index] = _concurrentTasks[index].copyWith( + status: status, + statusMessage: statusMessage, + errorMessage: errorMessage, + countdownTotal: countdownTotal, + ); + notifyListeners(); + } + } + + /// Update task countdown + void _updateTaskCountdown(int taskId, int remaining, int total) { + final index = _concurrentTasks.indexWhere((t) => t.taskId == taskId); + if (index != -1) { + _concurrentTasks[index] = _concurrentTasks[index].copyWith( + countdownRemaining: remaining, + countdownTotal: total, + ); + notifyListeners(); + } + } +} diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 0000000..c70a406 --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -0,0 +1,390 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Color scheme options for the app +enum AppColorScheme { blue, green, purple, orange } + +/// Provider for managing app theme and appearance settings +/// +/// Handles theme mode (light/dark/system), color scheme selection, +/// and persistence of user preferences +/// +/// Usage example: +/// ```dart +/// final themeProvider = Provider.of(context); +/// +/// // Change theme mode +/// themeProvider.setThemeMode(ThemeMode.dark); +/// +/// // Change color scheme +/// themeProvider.setColorScheme(AppColorScheme.green); +/// +/// // Get current theme data +/// final lightTheme = themeProvider.lightTheme; +/// final darkTheme = themeProvider.darkTheme; +/// ``` +class ThemeProvider extends ChangeNotifier { + static const String _themeModeKey = 'theme_mode'; + static const String _colorSchemeKey = 'color_scheme'; + + ThemeMode _themeMode = ThemeMode.system; + AppColorScheme _colorScheme = AppColorScheme.blue; + + ThemeProvider() { + _loadPreferences(); + } + + /// Get current theme mode + ThemeMode get themeMode => _themeMode; + + /// Get current color scheme + AppColorScheme get colorScheme => _colorScheme; + + /// Get light theme data + ThemeData get lightTheme => _buildLightTheme(); + + /// Get dark theme data + ThemeData get darkTheme => _buildDarkTheme(); + + /// Set theme mode + /// + /// [mode] - The theme mode to set (light, dark, or system) + /// Saves preference and notifies listeners + Future setThemeMode(ThemeMode mode) async { + if (_themeMode == mode) return; + + _themeMode = mode; + notifyListeners(); + await savePreferences(); + } + + /// Set color scheme + /// + /// [scheme] - The color scheme to set + /// Saves preference and notifies listeners + Future setColorScheme(AppColorScheme scheme) async { + if (_colorScheme == scheme) return; + + _colorScheme = scheme; + notifyListeners(); + await savePreferences(); + } + + /// Save preferences to local storage + Future savePreferences() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_themeModeKey, _themeMode.name); + await prefs.setString(_colorSchemeKey, _colorScheme.name); + } catch (e) { + debugPrint('Failed to save theme preferences: $e'); + } + } + + /// Load preferences from local storage + Future _loadPreferences() async { + try { + final prefs = await SharedPreferences.getInstance(); + + // Load theme mode + final themeModeStr = prefs.getString(_themeModeKey); + if (themeModeStr != null) { + _themeMode = ThemeMode.values.firstWhere( + (mode) => mode.name == themeModeStr, + orElse: () => ThemeMode.system, + ); + } + + // Load color scheme + final colorSchemeStr = prefs.getString(_colorSchemeKey); + if (colorSchemeStr != null) { + _colorScheme = AppColorScheme.values.firstWhere( + (scheme) => scheme.name == colorSchemeStr, + orElse: () => AppColorScheme.blue, + ); + } + + notifyListeners(); + } catch (e) { + debugPrint('Failed to load theme preferences: $e'); + } + } + + /// Build light theme based on current color scheme + ThemeData _buildLightTheme() { + final colorScheme = _getColorScheme(Brightness.light); + + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + colorScheme: colorScheme, + + // AppBar theme + appBarTheme: AppBarTheme( + centerTitle: true, + elevation: 0, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + ), + + // Card theme + cardTheme: const CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + + // Input decoration theme + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.error, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.error, width: 2), + ), + ), + + // Elevated button theme + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + + // Text button theme + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + + // Floating action button theme + floatingActionButtonTheme: FloatingActionButtonThemeData( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + + // Progress indicator theme + progressIndicatorTheme: ProgressIndicatorThemeData( + color: colorScheme.primary, + ), + + // Divider theme + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant, + thickness: 1, + ), + ); + } + + /// Build dark theme based on current color scheme + ThemeData _buildDarkTheme() { + final colorScheme = _getColorScheme(Brightness.dark); + + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: colorScheme, + + // AppBar theme + appBarTheme: AppBarTheme( + centerTitle: true, + elevation: 0, + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + ), + + // Card theme + cardTheme: const CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + + // Input decoration theme + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.error, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.error, width: 2), + ), + ), + + // Elevated button theme + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + + // Text button theme + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + + // Floating action button theme + floatingActionButtonTheme: FloatingActionButtonThemeData( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + + // Progress indicator theme + progressIndicatorTheme: ProgressIndicatorThemeData( + color: colorScheme.primary, + ), + + // Divider theme + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant, + thickness: 1, + ), + ); + } + + /// Get color scheme based on brightness and selected color + ColorScheme _getColorScheme(Brightness brightness) { + switch (_colorScheme) { + case AppColorScheme.blue: + return _getBlueColorScheme(brightness); + case AppColorScheme.green: + return _getGreenColorScheme(brightness); + case AppColorScheme.purple: + return _getPurpleColorScheme(brightness); + case AppColorScheme.orange: + return _getOrangeColorScheme(brightness); + } + } + + /// Blue color scheme + ColorScheme _getBlueColorScheme(Brightness brightness) { + if (brightness == Brightness.light) { + return ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.light, + ); + } else { + return ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.dark, + ); + } + } + + /// Green color scheme + ColorScheme _getGreenColorScheme(Brightness brightness) { + if (brightness == Brightness.light) { + return ColorScheme.fromSeed( + seedColor: Colors.green, + brightness: Brightness.light, + ); + } else { + return ColorScheme.fromSeed( + seedColor: Colors.green, + brightness: Brightness.dark, + ); + } + } + + /// Purple color scheme + ColorScheme _getPurpleColorScheme(Brightness brightness) { + if (brightness == Brightness.light) { + return ColorScheme.fromSeed( + seedColor: Colors.purple, + brightness: Brightness.light, + ); + } else { + return ColorScheme.fromSeed( + seedColor: Colors.purple, + brightness: Brightness.dark, + ); + } + } + + /// Orange color scheme + ColorScheme _getOrangeColorScheme(Brightness brightness) { + if (brightness == Brightness.light) { + return ColorScheme.fromSeed( + seedColor: Colors.orange, + brightness: Brightness.light, + ); + } else { + return ColorScheme.fromSeed( + seedColor: Colors.orange, + brightness: Brightness.dark, + ); + } + } + + /// Get color scheme name for display + String getColorSchemeName(AppColorScheme scheme) { + switch (scheme) { + case AppColorScheme.blue: + return '蓝色'; + case AppColorScheme.green: + return '绿色'; + case AppColorScheme.purple: + return '紫色'; + case AppColorScheme.orange: + return '橙色'; + } + } + + /// Get theme mode name for display + String getThemeModeName(ThemeMode mode) { + switch (mode) { + case ThemeMode.light: + return '浅色模式'; + case ThemeMode.dark: + return '深色模式'; + case ThemeMode.system: + return '跟随系统'; + } + } +} diff --git a/lib/screens/.gitkeep b/lib/screens/.gitkeep new file mode 100644 index 0000000..406fe01 --- /dev/null +++ b/lib/screens/.gitkeep @@ -0,0 +1,2 @@ +# Screens directory +# This directory contains UI screens diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..05e5b14 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import '../providers/evaluation_provider.dart'; +import '../widgets/course_card.dart'; +import 'progress_screen.dart'; +import 'settings_screen.dart'; + +/// Home screen displaying course list and evaluation controls +/// +/// Shows list of courses that need evaluation +/// Provides batch evaluation functionality +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + @override + void initState() { + super.initState(); + // Defer loading until after the first frame + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadCourses(); + }); + } + + Future _loadCourses() async { + if (!mounted) return; + + final authProvider = Provider.of(context, listen: false); + final evaluationProvider = Provider.of( + context, + listen: false, + ); + + print('🔍 _loadCourses called'); + print('🔍 authProvider.connection: ${authProvider.connection}'); + print('🔍 authProvider.isAuthenticated: ${authProvider.isAuthenticated}'); + + // Set connection for evaluation provider + if (authProvider.connection != null) { + print('🔍 Setting connection and loading courses...'); + evaluationProvider.setConnection(authProvider.connection); + final success = await evaluationProvider.loadCourses(); + print('🔍 loadCourses result: $success'); + print('🔍 courses count: ${evaluationProvider.courses.length}'); + if (!success) { + print('❌ Error: ${evaluationProvider.errorMessage}'); + } + } else { + print('❌ No connection available'); + } + } + + Future _handleRefresh() async { + await _loadCourses(); + } + + Future _handleBatchEvaluation() async { + final evaluationProvider = Provider.of( + context, + listen: false, + ); + + // Check if there are pending courses + if (evaluationProvider.pendingCourses.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('没有待评课程'))); + return; + } + + // Navigate to progress screen + if (!mounted) return; + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => const ProgressScreen())); + + // Start concurrent batch evaluation + await evaluationProvider.startConcurrentBatchEvaluation(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('课程评教'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _handleRefresh, + tooltip: '刷新', + ), + IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ); + }, + tooltip: '设置', + ), + ], + ), + body: Consumer( + builder: (context, evaluationProvider, child) { + if (evaluationProvider.state == EvaluationState.loading) { + return const Center(child: CircularProgressIndicator()); + } + + if (evaluationProvider.state == EvaluationState.error) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 16), + Text( + evaluationProvider.errorMessage ?? '加载失败', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _handleRefresh, + icon: const Icon(Icons.refresh), + label: const Text('重试'), + ), + ], + ), + ); + } + + final courses = evaluationProvider.courses; + final pendingCount = evaluationProvider.pendingCourses.length; + final evaluatedCount = evaluationProvider.evaluatedCourses.length; + + if (courses.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check_circle_outline, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + '暂无待评课程', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + '下拉刷新以检查新课程', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + return Column( + children: [ + // Statistics card + Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem( + context, + '总计', + courses.length.toString(), + Icons.list_alt, + ), + _buildStatItem( + context, + '待评', + pendingCount.toString(), + Icons.pending_actions, + ), + _buildStatItem( + context, + '已评', + evaluatedCount.toString(), + Icons.check_circle, + ), + ], + ), + ), + + // Course list + Expanded( + child: RefreshIndicator( + onRefresh: _handleRefresh, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: courses.length, + itemBuilder: (context, index) { + final course = courses[index]; + return CourseCard(course: course); + }, + ), + ), + ), + ], + ); + }, + ), + floatingActionButton: Consumer( + builder: (context, evaluationProvider, child) { + final hasPending = evaluationProvider.pendingCourses.isNotEmpty; + final isEvaluating = + evaluationProvider.state == EvaluationState.evaluating; + + if (!hasPending || isEvaluating) { + return const SizedBox.shrink(); + } + + return FloatingActionButton.extended( + onPressed: _handleBatchEvaluation, + icon: const Icon(Icons.play_arrow), + label: const Text('批量评教'), + ); + }, + ), + ); + } + + Widget _buildStatItem( + BuildContext context, + String label, + String value, + IconData icon, + ) { + return Column( + children: [ + Icon(icon, color: Theme.of(context).colorScheme.onPrimaryContainer), + const SizedBox(height: 4), + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ], + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..d1e2468 --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import 'home_screen.dart'; + +/// Login screen for user authentication +/// +/// Provides input fields for student ID, EC password, and UAAP password +/// Integrates with AuthProvider for authentication +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _userIdController = TextEditingController(); + final _ecPasswordController = TextEditingController(); + final _passwordController = TextEditingController(); + + bool _obscureEcPassword = true; + bool _obscurePassword = true; + + @override + void dispose() { + _userIdController.dispose(); + _ecPasswordController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _handleLogin() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final authProvider = Provider.of(context, listen: false); + + final success = await authProvider.login( + userId: _userIdController.text.trim(), + ecPassword: _ecPasswordController.text, + password: _passwordController.text, + ); + + if (!mounted) return; + + if (success) { + // Navigate to home screen + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const HomeScreen()), + ); + } else { + // Show error message + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(authProvider.errorMessage ?? '登录失败'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // App logo/title + Icon( + Icons.school, + size: 80, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + '自动评教系统', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + '安徽财经大学', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + + // Student ID field + TextFormField( + controller: _userIdController, + decoration: const InputDecoration( + labelText: '学号', + hintText: '请输入学号', + prefixIcon: Icon(Icons.person), + ), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入学号'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // EC password field + TextFormField( + controller: _ecPasswordController, + decoration: InputDecoration( + labelText: 'EC密码', + hintText: '请输入EC系统密码', + prefixIcon: const Icon(Icons.lock), + suffixIcon: IconButton( + icon: Icon( + _obscureEcPassword + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscureEcPassword = !_obscureEcPassword; + }); + }, + ), + ), + obscureText: _obscureEcPassword, + validator: (value) { + if (value == null || value.isEmpty) { + return '请输入EC密码'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // UAAP password field + TextFormField( + controller: _passwordController, + decoration: InputDecoration( + labelText: 'UAAP密码', + hintText: '请输入UAAP系统密码', + prefixIcon: const Icon(Icons.vpn_key), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + obscureText: _obscurePassword, + validator: (value) { + if (value == null || value.isEmpty) { + return '请输入UAAP密码'; + } + return null; + }, + ), + const SizedBox(height: 32), + + // Login button + Consumer( + builder: (context, authProvider, child) { + final isLoading = authProvider.state == AuthState.loading; + + return ElevatedButton( + onPressed: isLoading ? null : _handleLogin, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimary, + ), + child: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Text('登录', style: TextStyle(fontSize: 16)), + ); + }, + ), + const SizedBox(height: 16), + + // Help text + Text( + '首次登录需要输入EC和UAAP系统密码\n登录信息将被安全加密存储', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + + // Signature + Column( + children: [ + Text( + '❤ Created By LoveACE Team', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withOpacity(0.6), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + '🌧 Powered By Sibuxiangx & Flutter', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withOpacity(0.6), + ), + textAlign: TextAlign.center, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/progress_screen.dart b/lib/screens/progress_screen.dart new file mode 100644 index 0000000..6e0a8e4 --- /dev/null +++ b/lib/screens/progress_screen.dart @@ -0,0 +1,647 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/evaluation_provider.dart'; + +/// Progress screen showing real-time evaluation progress +/// +/// Displays progress percentage, current course, and statistics +/// Allows canceling the evaluation process +class ProgressScreen extends StatelessWidget { + const ProgressScreen({super.key}); + + Future _onWillPop(BuildContext context, EvaluationState state) async { + // 如果已完成或出错,允许直接返回 + if (state == EvaluationState.completed || state == EvaluationState.error) { + return true; + } + + // 如果正在评教,显示确认对话框 + final shouldPop = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('确认中断'), + content: const Text('评教正在进行中,确定要中断吗?\n\n中断后当前进度将丢失。'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('继续评教'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('中断'), + ), + ], + ), + ); + + return shouldPop ?? false; + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, evaluationProvider, child) { + final state = evaluationProvider.state; + final progress = evaluationProvider.progress; + final current = evaluationProvider.currentProgress; + final total = evaluationProvider.totalProgress; + final currentCourse = evaluationProvider.currentCourse; + final currentStatus = evaluationProvider.currentStatus; + final lastResult = evaluationProvider.lastResult; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + + final shouldPop = await _onWillPop(context, state); + if (shouldPop && context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Scaffold( + appBar: AppBar( + title: const Text('评教进度'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { + final shouldPop = await _onWillPop(context, state); + if (shouldPop && context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + ), + body: Builder( + builder: (context) { + // Show completion screen + if (state == EvaluationState.completed && lastResult != null) { + return _buildCompletionScreen(context, lastResult); + } + + // Show error screen + if (state == EvaluationState.error) { + return _buildErrorScreen( + context, + evaluationProvider.errorMessage ?? '评教失败', + ); + } + + // Show progress screen + return _buildProgressScreen( + context, + progress, + current, + total, + currentCourse, + currentStatus, + ); + }, + ), + ), + ); + }, + ); + } + + Widget _buildProgressScreen( + BuildContext context, + double progress, + int current, + int total, + dynamic currentCourse, + String? status, + ) { + return Column( + children: [ + // Top: Overall progress info + Expanded( + flex: 2, + child: _buildProgressContent( + context, + progress, + current, + total, + currentCourse, + status, + ), + ), + // Bottom: Task list + Expanded(flex: 3, child: _buildTaskListPanel(context)), + ], + ); + } + + Widget _buildProgressContent( + BuildContext context, + double progress, + int current, + int total, + dynamic currentCourse, + String? status, + ) { + return Center( + child: Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Progress percentage + Text( + '${(progress * 100).toInt()}%', + style: Theme.of(context).textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(height: 16), + + // Progress bar + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: LinearProgressIndicator( + value: progress, + minHeight: 24, + backgroundColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 16), + + // Complete / All + Text( + '$current / $total 节', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ], + ), + ), + ); + } + + Widget _buildTaskListPanel(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + final tasks = provider.concurrentTasks; + + return Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + Icon( + Icons.list_alt, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + '并发任务列表', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Text( + '${tasks.length} 个任务', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Task list + Expanded( + child: tasks.isEmpty + ? Center( + child: Text( + '暂无任务', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return _buildTaskCard(context, task); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildTaskCard(BuildContext context, dynamic task) { + final statusColor = _getTaskStatusColor(context, task.status); + final statusIcon = _getTaskStatusIcon(task.status); + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Task header + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: statusColor.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 14, color: statusColor), + const SizedBox(width: 4), + Text( + '任务 ${task.taskId}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + task.course.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + + // Teacher info + Row( + children: [ + Icon( + Icons.person, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + task.course.teacher, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 8), + + // Status and progress + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.statusText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: task.progress, + minHeight: 6, + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + statusColor, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + '${(task.progress * 100).toInt()}%', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + + // Error message if failed + if (task.status.toString().contains('failed') && + task.errorMessage != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 16, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + task.errorMessage, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } + + Color _getTaskStatusColor(BuildContext context, dynamic status) { + final statusStr = status.toString(); + if (statusStr.contains('completed')) { + return Colors.green; + } else if (statusStr.contains('failed')) { + return Theme.of(context).colorScheme.error; + } else if (statusStr.contains('countdown')) { + return Theme.of(context).colorScheme.tertiary; + } else if (statusStr.contains('submitting') || + statusStr.contains('verifying')) { + return Theme.of(context).colorScheme.secondary; + } else if (statusStr.contains('preparing')) { + return Theme.of(context).colorScheme.primary; + } else { + return Theme.of(context).colorScheme.onSurfaceVariant; + } + } + + IconData _getTaskStatusIcon(dynamic status) { + final statusStr = status.toString(); + if (statusStr.contains('completed')) { + return Icons.check_circle; + } else if (statusStr.contains('failed')) { + return Icons.error; + } else if (statusStr.contains('countdown')) { + return Icons.timer; + } else if (statusStr.contains('submitting')) { + return Icons.upload; + } else if (statusStr.contains('verifying')) { + return Icons.verified; + } else if (statusStr.contains('preparing')) { + return Icons.settings; + } else { + return Icons.pending; + } + } + + Widget _buildCompletionScreen(BuildContext context, dynamic result) { + final isAllSuccess = result.failed == 0; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 40), + + // Success/Warning icon + Icon( + isAllSuccess ? Icons.check_circle : Icons.warning, + size: 100, + color: isAllSuccess + ? Colors.green + : Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 24), + + // Title + Text( + isAllSuccess ? '评教完成' : '评教完成(部分失败)', + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 32), + + // Statistics + _buildResultCard( + context, + '总计', + result.total.toString(), + Icons.list_alt, + Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 12), + _buildResultCard( + context, + '成功', + result.success.toString(), + Icons.check_circle, + Colors.green, + ), + if (result.failed > 0) ...[ + const SizedBox(height: 12), + _buildResultCard( + context, + '失败', + result.failed.toString(), + Icons.error, + Theme.of(context).colorScheme.error, + ), + ], + const SizedBox(height: 12), + _buildResultCard( + context, + '耗时', + '${result.duration.inSeconds}秒', + Icons.timer, + Theme.of(context).colorScheme.secondary, + ), + const SizedBox(height: 48), + + // Action buttons + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (result.failed > 0) + ElevatedButton.icon( + onPressed: () { + final evaluationProvider = Provider.of( + context, + listen: false, + ); + evaluationProvider.retryFailed(); + }, + icon: const Icon(Icons.refresh), + label: const Text('重试失败'), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + ), + if (result.failed > 0) const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.home), + label: const Text('返回首页'), + ), + ], + ), + + const SizedBox(height: 40), + ], + ), + ); + } + + Widget _buildResultCard( + BuildContext context, + String label, + String value, + IconData icon, + Color color, + ) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 32), + const SizedBox(width: 16), + Expanded( + child: Text(label, style: Theme.of(context).textTheme.titleMedium), + ), + Text( + value, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ); + } + + Widget _buildErrorScreen(BuildContext context, String errorMessage) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 80), + + Icon( + Icons.error_outline, + size: 100, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(height: 24), + Text( + '评教失败', + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Text( + errorMessage, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.home), + label: const Text('返回首页'), + ), + + const SizedBox(height: 80), + ], + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..4264c66 --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,368 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/auth_provider.dart'; +import '../providers/theme_provider.dart'; +import '../providers/evaluation_provider.dart'; +import '../widgets/confirm_dialog.dart'; +import 'login_screen.dart'; + +/// Settings screen for app configuration +/// +/// Provides theme customization, logout, and data management +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + Future _handleLogout(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => const ConfirmDialog( + title: '退出登录', + content: '确定要退出登录吗?', + confirmText: '退出', + cancelText: '取消', + ), + ); + + if (confirmed != true || !context.mounted) return; + + final authProvider = Provider.of(context, listen: false); + final evaluationProvider = Provider.of( + context, + listen: false, + ); + + await authProvider.logout(); + evaluationProvider.reset(); + + if (!context.mounted) return; + + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const LoginScreen()), + (route) => false, + ); + } + + Future _handleClearData(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => const ConfirmDialog( + title: '清除数据', + content: '确定要清除所有本地数据吗?\n这将删除评教历史记录。', + confirmText: '清除', + cancelText: '取消', + ), + ); + + if (confirmed != true || !context.mounted) return; + + final evaluationProvider = Provider.of( + context, + listen: false, + ); + await evaluationProvider.clearEvaluationHistory(); + + if (!context.mounted) return; + + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('数据已清除'))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('设置')), + body: ListView( + children: [ + // Theme section + _buildSectionHeader(context, '外观设置'), + _buildThemeModeSelector(context), + _buildColorSchemeSelector(context), + const Divider(height: 32), + + // Account section + _buildSectionHeader(context, '账号管理'), + _buildAccountInfo(context), + _buildLogoutTile(context), + const Divider(height: 32), + + // Data section + _buildSectionHeader(context, '数据管理'), + _buildClearDataTile(context), + const Divider(height: 32), + + // About section + _buildSectionHeader(context, '关于'), + _buildAboutTile(context), + _buildFontLicenseTile(context), + ], + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildThemeModeSelector(BuildContext context) { + return Consumer( + builder: (context, themeProvider, child) { + return ListTile( + leading: const Icon(Icons.brightness_6), + title: const Text('主题模式'), + subtitle: Text( + themeProvider.getThemeModeName(themeProvider.themeMode), + ), + onTap: () { + showDialog( + context: context, + builder: (context) => _ThemeModeDialog( + currentMode: themeProvider.themeMode, + onModeSelected: (mode) { + themeProvider.setThemeMode(mode); + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + }, + ); + } + + Widget _buildColorSchemeSelector(BuildContext context) { + return Consumer( + builder: (context, themeProvider, child) { + return ListTile( + leading: const Icon(Icons.palette), + title: const Text('颜色方案'), + subtitle: Text( + themeProvider.getColorSchemeName(themeProvider.colorScheme), + ), + onTap: () { + showDialog( + context: context, + builder: (context) => _ColorSchemeDialog( + currentScheme: themeProvider.colorScheme, + onSchemeSelected: (scheme) { + themeProvider.setColorScheme(scheme); + Navigator.of(context).pop(); + }, + ), + ); + }, + ); + }, + ); + } + + Widget _buildAccountInfo(BuildContext context) { + return Consumer( + builder: (context, authProvider, child) { + final credentials = authProvider.credentials; + return ListTile( + leading: const Icon(Icons.account_circle), + title: const Text('当前账号'), + subtitle: Text(credentials?.userId ?? '未登录'), + ); + }, + ); + } + + Widget _buildLogoutTile(BuildContext context) { + return ListTile( + leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error), + title: Text( + '退出登录', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + onTap: () => _handleLogout(context), + ); + } + + Widget _buildClearDataTile(BuildContext context) { + return ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('清除本地数据'), + subtitle: const Text('删除评教历史记录'), + onTap: () => _handleClearData(context), + ); + } + + Widget _buildAboutTile(BuildContext context) { + return ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('关于应用'), + subtitle: const Text('版本 1.0.0'), + onTap: () { + showAboutDialog( + context: context, + applicationName: '自动评教系统', + applicationVersion: '1.0.0', + applicationIcon: Icon( + Icons.school, + size: 48, + color: Theme.of(context).colorScheme.primary, + ), + children: [ + const Text('AUFE自动评教工具'), + const SizedBox(height: 8), + const Text('帮助学生快速完成课程评教任务'), + ], + ); + }, + ); + } + + Widget _buildFontLicenseTile(BuildContext context) { + return ListTile( + leading: const Icon(Icons.font_download), + title: const Text('字体许可'), + subtitle: const Text('MiSans 字体使用说明'), + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('MiSans 字体知识产权许可协议'), + content: const SingleChildScrollView( + child: Text( + '本应用在 Windows 平台使用 MiSans 字体。\n\n' + '根据小米科技有限责任公司的授权,MiSans 字体可免费用于个人和商业用途。\n\n' + '使用条件:\n' + '• 应特别注明使用了 MiSans 字体\n' + '• 不得对字体进行改编或二次开发\n' + '• 不得单独分发或售卖字体文件\n' + '• 可自由分发使用该字体创作的作品\n\n' + '本应用遵守以上使用条款。', + style: TextStyle(fontSize: 14), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('关闭'), + ), + ], + ), + ); + }, + ); + } +} + +class _ThemeModeDialog extends StatelessWidget { + final ThemeMode currentMode; + final Function(ThemeMode) onModeSelected; + + const _ThemeModeDialog({ + required this.currentMode, + required this.onModeSelected, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('选择主题模式'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + title: const Text('浅色模式'), + value: ThemeMode.light, + groupValue: currentMode, + onChanged: (mode) { + if (mode != null) onModeSelected(mode); + }, + ), + RadioListTile( + title: const Text('深色模式'), + value: ThemeMode.dark, + groupValue: currentMode, + onChanged: (mode) { + if (mode != null) onModeSelected(mode); + }, + ), + RadioListTile( + title: const Text('跟随系统'), + value: ThemeMode.system, + groupValue: currentMode, + onChanged: (mode) { + if (mode != null) onModeSelected(mode); + }, + ), + ], + ), + ); + } +} + +class _ColorSchemeDialog extends StatelessWidget { + final AppColorScheme currentScheme; + final Function(AppColorScheme) onSchemeSelected; + + const _ColorSchemeDialog({ + required this.currentScheme, + required this.onSchemeSelected, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('选择颜色方案'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildColorOption(context, AppColorScheme.blue, '蓝色', Colors.blue), + _buildColorOption(context, AppColorScheme.green, '绿色', Colors.green), + _buildColorOption( + context, + AppColorScheme.purple, + '紫色', + Colors.purple, + ), + _buildColorOption( + context, + AppColorScheme.orange, + '橙色', + Colors.orange, + ), + ], + ), + ); + } + + Widget _buildColorOption( + BuildContext context, + AppColorScheme scheme, + String name, + Color color, + ) { + return RadioListTile( + title: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 12), + Text(name), + ], + ), + value: scheme, + groupValue: currentScheme, + onChanged: (scheme) { + if (scheme != null) onSchemeSelected(scheme); + }, + ); + } +} diff --git a/lib/services/.gitkeep b/lib/services/.gitkeep new file mode 100644 index 0000000..6465d42 --- /dev/null +++ b/lib/services/.gitkeep @@ -0,0 +1,2 @@ +# Services directory +# This directory contains business logic services diff --git a/lib/services/app_initialization_service.dart b/lib/services/app_initialization_service.dart new file mode 100644 index 0000000..f878ff8 --- /dev/null +++ b/lib/services/app_initialization_service.dart @@ -0,0 +1,275 @@ +import 'package:flutter/foundation.dart'; +import '../providers/auth_provider.dart'; +import '../services/notification_service.dart'; +import '../services/storage_service.dart'; +import '../utils/session_manager.dart'; + +/// Service for handling app initialization tasks +/// +/// Coordinates initialization of various services and providers: +/// - Theme preferences loading +/// - Notification service setup +/// - Session restoration +/// - Cache management +/// +/// Usage example: +/// ```dart +/// final initService = AppInitializationService( +/// authProvider: authProvider, +/// themeProvider: themeProvider, +/// notificationService: notificationService, +/// ); +/// +/// final result = await initService.initialize(); +/// if (result.success) { +/// // App initialized successfully +/// } +/// ``` +class AppInitializationService { + final AuthProvider _authProvider; + final NotificationService _notificationService; + final StorageService _storageService; + late final SessionManager _sessionManager; + + AppInitializationService({ + required AuthProvider authProvider, + required NotificationService notificationService, + StorageService? storageService, + }) : _authProvider = authProvider, + _notificationService = notificationService, + _storageService = storageService ?? StorageService() { + _sessionManager = SessionManager(authProvider: _authProvider); + } + + /// Initialize the application + /// + /// Performs all necessary initialization tasks in the correct order + /// Returns InitializationResult with status and details + Future initialize() async { + final startTime = DateTime.now(); + final steps = []; + + try { + // Step 1: Initialize notification service + steps.add(await _initializeNotifications()); + + // Step 2: Load theme preferences + steps.add(await _loadThemePreferences()); + + // Step 3: Check and manage cache + steps.add(await _manageCacheVersion()); + + // Step 4: Attempt session restoration + steps.add(await _restoreSession()); + + final duration = DateTime.now().difference(startTime); + final allSuccessful = steps.every((step) => step.success); + + return InitializationResult( + success: allSuccessful, + steps: steps, + duration: duration, + sessionRestored: steps + .firstWhere( + (s) => s.name == 'Session Restoration', + orElse: () => InitializationStep( + name: 'Session Restoration', + success: false, + ), + ) + .success, + ); + } catch (e) { + debugPrint('Error during app initialization: $e'); + final duration = DateTime.now().difference(startTime); + + return InitializationResult( + success: false, + steps: steps, + duration: duration, + error: e.toString(), + ); + } + } + + /// Initialize notification service + Future _initializeNotifications() async { + try { + await _notificationService.initialize(); + return InitializationStep( + name: 'Notification Service', + success: true, + message: 'Notification service initialized', + ); + } catch (e) { + debugPrint('Failed to initialize notifications: $e'); + return InitializationStep( + name: 'Notification Service', + success: false, + message: 'Failed to initialize notifications: $e', + ); + } + } + + /// Load theme preferences + Future _loadThemePreferences() async { + try { + // Theme preferences are loaded in ThemeProvider constructor + // This step just confirms it's ready + return InitializationStep( + name: 'Theme Preferences', + success: true, + message: 'Theme preferences loaded', + ); + } catch (e) { + debugPrint('Failed to load theme preferences: $e'); + return InitializationStep( + name: 'Theme Preferences', + success: false, + message: 'Failed to load theme preferences: $e', + ); + } + } + + /// Manage cache version + Future _manageCacheVersion() async { + try { + const expectedCacheVersion = 1; // Update this when data structure changes + final cleared = await _storageService.clearCacheIfVersionMismatch( + expectedCacheVersion, + ); + + if (cleared) { + return InitializationStep( + name: 'Cache Management', + success: true, + message: 'Cache cleared due to version mismatch', + ); + } else { + return InitializationStep( + name: 'Cache Management', + success: true, + message: 'Cache version is current', + ); + } + } catch (e) { + debugPrint('Failed to manage cache: $e'); + return InitializationStep( + name: 'Cache Management', + success: false, + message: 'Failed to manage cache: $e', + ); + } + } + + /// Attempt to restore previous session + Future _restoreSession() async { + try { + // Check if session can be restored + final canRestore = await _sessionManager.canRestoreSession(); + + if (!canRestore) { + return InitializationStep( + name: 'Session Restoration', + success: false, + message: 'No saved session to restore', + ); + } + + // Attempt restoration + final result = await _sessionManager.restoreSession(); + + if (result.success) { + return InitializationStep( + name: 'Session Restoration', + success: true, + message: 'Session restored successfully', + ); + } else { + // Session restoration failed, but this is not a critical error + // User will just need to login again + return InitializationStep( + name: 'Session Restoration', + success: false, + message: result.message, + reason: result.reason, + ); + } + } catch (e) { + debugPrint('Error during session restoration: $e'); + return InitializationStep( + name: 'Session Restoration', + success: false, + message: 'Session restoration error: $e', + ); + } + } + + /// Get session manager instance + SessionManager get sessionManager => _sessionManager; + + /// Get storage service instance + StorageService get storageService => _storageService; +} + +/// Result of app initialization +class InitializationResult { + final bool success; + final List steps; + final Duration duration; + final bool sessionRestored; + final String? error; + + InitializationResult({ + required this.success, + required this.steps, + required this.duration, + this.sessionRestored = false, + this.error, + }); + + /// Get failed steps + List get failedSteps => + steps.where((step) => !step.success).toList(); + + /// Get successful steps + List get successfulSteps => + steps.where((step) => step.success).toList(); + + /// Check if initialization is complete (even with some non-critical failures) + bool get isComplete => steps.isNotEmpty; + + @override + String toString() { + return 'InitializationResult(' + 'success: $success, ' + 'sessionRestored: $sessionRestored, ' + 'steps: ${steps.length}, ' + 'duration: ${duration.inMilliseconds}ms' + ')'; + } +} + +/// Individual initialization step +class InitializationStep { + final String name; + final bool success; + final String? message; + final SessionRestoreFailureReason? reason; + + InitializationStep({ + required this.name, + required this.success, + this.message, + this.reason, + }); + + @override + String toString() { + return 'InitializationStep(' + 'name: $name, ' + 'success: $success, ' + 'message: $message' + ')'; + } +} diff --git a/lib/services/aufe_connection.dart b/lib/services/aufe_connection.dart new file mode 100644 index 0000000..dd6c3c8 --- /dev/null +++ b/lib/services/aufe_connection.dart @@ -0,0 +1,539 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:dio/dio.dart'; +import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart'; +import '../models/login_status.dart'; +import '../models/course.dart'; +import 'http_client.dart'; +import '../utils/retry_handler.dart'; + +/// AUFE教务系统连接类 +class AUFEConnection { + final String userId; + final String ecPassword; + final String password; + + late HTTPClient _client; + String? _twfId; + String? _token; + bool _ecLogged = false; + bool _uaapLogged = false; + DateTime _lastCheck = DateTime.now(); + + // 配置常量 + static const String serverUrl = 'https://vpn.aufe.edu.cn'; + static const String uaapLoginUrl = + 'http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3A%2F%2Fjwcxk2.aufe.edu.cn%2Fj_spring_cas_security_check'; + static const String uaapCheckUrl = + 'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/'; + static const String ecCheckUrl = + 'http://txzx-aufe-edu-cn-s.vpn2.aufe.edu.cn:8118/dzzy/list.htm'; + + AUFEConnection({ + required this.userId, + required this.ecPassword, + required this.password, + }); + + /// 初始化HTTP客户端 + void startClient() { + _client = HTTPClient(baseUrl: serverUrl, timeout: 30000); + } + + /// EC系统登录(RSA加密) + Future ecLogin() async { + try { + return await RetryHandler.retry( + operation: () async => await _performEcLogin(), + retryIf: RetryHandler.shouldRetryOnError, + maxAttempts: 3, + ); + } catch (e) { + return ECLoginStatus(failUnknownError: true); + } + } + + Future _performEcLogin() async { + try { + // 1. 获取认证参数 + final response = await _client.get('/por/login_auth.csp?apiversion=1'); + final responseText = response.data.toString(); + + // 2. 提取TwfID + final twfIdMatch = RegExp( + r'(.*?)', + ).firstMatch(responseText); + if (twfIdMatch == null) { + return ECLoginStatus(failNotFoundTwfid: true); + } + _twfId = twfIdMatch.group(1); + + // 3. 提取RSA密钥 + final rsaKeyMatch = RegExp( + r'(.*?)', + ).firstMatch(responseText); + if (rsaKeyMatch == null) { + return ECLoginStatus(failNotFoundRsaKey: true); + } + final rsaKey = rsaKeyMatch.group(1)!; + + // 4. 提取RSA指数 + final rsaExpMatch = RegExp( + r'(.*?)', + ).firstMatch(responseText); + if (rsaExpMatch == null) { + return ECLoginStatus(failNotFoundRsaExp: true); + } + final rsaExp = rsaExpMatch.group(1)!; + + // 5. 提取CSRF代码 + final csrfMatch = RegExp( + r'(.*?)', + ).firstMatch(responseText); + if (csrfMatch == null) { + return ECLoginStatus(failNotFoundCsrfCode: true); + } + final csrfCode = csrfMatch.group(1)!; + + // 6. RSA加密密码 + final passwordToEncrypt = '${ecPassword}_$csrfCode'; + final encryptedPassword = _rsaEncrypt(passwordToEncrypt, rsaKey, rsaExp); + + // 7. 执行登录 + final loginResponse = await _client.post( + '/por/login_psw.csp?anti_replay=1&encrypt=1&type=cs', + data: { + 'svpn_rand_code': '', + 'mitm': '', + 'svpn_req_randcode': csrfCode, + 'svpn_name': userId, + 'svpn_password': encryptedPassword, + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + headers: {'Cookie': 'TWFID=$_twfId'}, + ), + ); + + final loginResponseText = loginResponse.data.toString(); + + // 8. 检查登录结果 + if (loginResponseText.contains('1')) { + _client.setCookie('TWFID', _twfId!); + _ecLogged = true; + return ECLoginStatus(success: true); + } else if (loginResponseText.contains('Invalid username or password!')) { + return ECLoginStatus(failInvalidCredentials: true); + } else if (loginResponseText.contains('[CDATA[maybe attacked]]') || + loginResponseText.contains('CAPTCHA required')) { + return ECLoginStatus(failMaybeAttacked: true); + } else { + return ECLoginStatus(failUnknownError: true); + } + } on DioException catch (e) { + return ECLoginStatus(failNetworkError: true); + } catch (e) { + return ECLoginStatus(failUnknownError: true); + } + } + + /// RSA加密 + String _rsaEncrypt(String plaintext, String modulusHex, String exponentStr) { + // 解析模数和指数 + final modulus = BigInt.parse(modulusHex, radix: 16); + final exponent = BigInt.parse(exponentStr); + + // 创建RSA公钥 + final publicKey = RSAPublicKey(modulus, exponent); + + // 创建加密器 + final encryptor = PKCS1Encoding(RSAEngine()); + encryptor.init(true, PublicKeyParameter(publicKey)); + + // 加密 + final plainBytes = utf8.encode(plaintext); + final encrypted = encryptor.process(Uint8List.fromList(plainBytes)); + + // 转换为十六进制字符串 + return encrypted.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + } + + /// UAAP系统登录(DES加密) + Future uaapLogin() async { + try { + return await RetryHandler.retry( + operation: () async => await _performUaapLogin(), + retryIf: RetryHandler.shouldRetryOnError, + maxAttempts: 3, + ); + } catch (e) { + return UAAPLoginStatus(failUnknownError: true); + } + } + + Future _performUaapLogin() async { + try { + // 1. 获取登录页面 + final response = await _client.get(uaapLoginUrl); + final responseText = response.data.toString(); + + // 2. 提取lt参数 + final ltMatch = RegExp( + r'name="lt" value="(.*?)"', + ).firstMatch(responseText); + if (ltMatch == null) { + return UAAPLoginStatus(failNotFoundLt: true); + } + final ltValue = ltMatch.group(1)!; + + // 3. 提取execution参数 + final executionMatch = RegExp( + r'name="execution" value="(.*?)"', + ).firstMatch(responseText); + if (executionMatch == null) { + return UAAPLoginStatus(failNotFoundExecution: true); + } + final executionValue = executionMatch.group(1)!; + + // 4. DES加密密码 + final encryptedPassword = _desEncrypt(password, ltValue); + + // 5. 提交登录表单 + final loginResponse = await _client.post( + uaapLoginUrl, + data: { + 'username': userId, + 'password': encryptedPassword, + 'lt': ltValue, + 'execution': executionValue, + '_eventId': 'submit', + 'submit': 'LOGIN', + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + followRedirects: false, + validateStatus: (status) => status! < 500, + ), + ); + + // 6. 检查登录结果并访问重定向URL以建立session + if (loginResponse.statusCode == 302) { + final location = loginResponse.headers['location']?.first ?? ''; + print('🔐 UAAP redirect location: $location'); + + if (location.contains('ticket=')) { + // 访问重定向URL以完成CAS认证并建立session + print('🔐 Following redirect to establish session...'); + final ticketResponse = await _client.get( + location, + options: Options( + followRedirects: true, + validateStatus: (status) => status! < 500, + ), + ); + print('🔐 Ticket response status: ${ticketResponse.statusCode}'); + + _uaapLogged = true; + return UAAPLoginStatus(success: true); + } + } + + final loginResponseText = loginResponse.data.toString(); + if (loginResponseText.contains('Invalid username or password')) { + return UAAPLoginStatus(failInvalidCredentials: true); + } + + return UAAPLoginStatus(failUnknownError: true); + } on DioException catch (e) { + return UAAPLoginStatus(failNetworkError: true); + } catch (e) { + return UAAPLoginStatus(failUnknownError: true); + } + } + + /// DES加密(使用TripleDES ECB模式) + String _desEncrypt(String plaintext, String key) { + // 处理密钥 - 取前8字节 + var keyBytes = utf8.encode(key); + if (keyBytes.length > 8) { + keyBytes = keyBytes.sublist(0, 8); + } else if (keyBytes.length < 8) { + // 不足8字节用0填充 + keyBytes = Uint8List(8)..setRange(0, keyBytes.length, keyBytes); + } + + // 创建DES密钥(TripleDES使用相同的8字节密钥重复3次) + final desKey = KeyParameter( + Uint8List(24) + ..setRange(0, 8, keyBytes) + ..setRange(8, 16, keyBytes) + ..setRange(16, 24, keyBytes), + ); + + // 创建加密器 + final cipher = PaddedBlockCipherImpl(PKCS7Padding(), DESedeEngine()); + cipher.init(true, PaddedBlockCipherParameters(desKey, null)); + + // 加密 + final plainBytes = utf8.encode(plaintext); + final encrypted = cipher.process(Uint8List.fromList(plainBytes)); + + // Base64编码 + return base64.encode(encrypted); + } + + /// 检查EC登录状态 + Future checkEcLoginStatus() async { + if (!_ecLogged) { + return ECCheckStatus(loggedIn: false); + } + + try { + final response = await _client.get(ecCheckUrl); + if (response.statusCode == 200) { + return ECCheckStatus(loggedIn: true); + } else { + return ECCheckStatus(loggedIn: false); + } + } on DioException catch (e) { + return ECCheckStatus(failNetworkError: true); + } catch (e) { + return ECCheckStatus(failUnknownError: true); + } + } + + /// 检查UAAP登录状态 + Future checkUaapLoginStatus() async { + return ECCheckStatus(loggedIn: _uaapLogged); + } + + /// 健康检查 + Future healthCheck() async { + final delta = DateTime.now().difference(_lastCheck); + + // 5分钟未检查则视为不健康 + if (delta.inSeconds > 300) { + return false; + } + + // 检查UAAP登录状态 + final uaapStatus = await checkUaapLoginStatus(); + if (!uaapStatus.isLoggedIn) { + return false; + } + + // 检查EC登录状态 + final ecStatus = await checkEcLoginStatus(); + if (!ecStatus.isLoggedIn) { + return false; + } + + return true; + } + + /// 更新健康检查时间戳 + void healthCheckpoint() { + _lastCheck = DateTime.now(); + } + + /// 关闭连接 + Future close() async { + _client.close(); + } + + /// 获取HTTP客户端 + HTTPClient get client { + healthCheckpoint(); + return _client; + } + + /// 获取CSRF Token + Future getToken() async { + try { + final response = await _client.get( + 'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/evaluation/index', + ); + + if (response.statusCode != 200) { + return null; + } + + final html = response.data.toString(); + final tokenMatch = RegExp( + r'id="tokenValue"[^>]*value="([^"]*)"', + ).firstMatch(html); + + if (tokenMatch != null) { + _token = tokenMatch.group(1); + return _token; + } + + return null; + } catch (e) { + return null; + } + } + + /// 获取待评课程列表 + Future> fetchCourseList() async { + try { + final response = await _client.post( + 'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/search?sf_request_type=ajax', + data: {'optType': '1', 'pagesize': '50'}, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + + if (response.statusCode != 200) { + return []; + } + + final data = response.data; + if (data is Map && data['data'] is List) { + final courseList = (data['data'] as List) + .map((item) => _parseCourse(item)) + .where((course) => course != null) + .cast() + .toList(); + return courseList; + } + + return []; + } catch (e) { + return []; + } + } + + /// 解析课程数据 + Course? _parseCourse(dynamic item) { + try { + if (item is! Map) return null; + + final id = item['id'] as Map?; + final questionnaire = item['questionnaire'] as Map?; + + return Course( + id: id?['evaluatedPeople']?.toString() ?? '', + name: item['evaluationContent']?.toString() ?? '', + teacher: item['evaluatedPeople']?.toString() ?? '', + evaluatedPeople: item['evaluatedPeople']?.toString() ?? '', + evaluatedPeopleNumber: id?['evaluatedPeople']?.toString() ?? '', + coureSequenceNumber: id?['coureSequenceNumber']?.toString() ?? '', + evaluationContentNumber: + id?['evaluationContentNumber']?.toString() ?? '', + questionnaireCode: + questionnaire?['questionnaireNumber']?.toString() ?? '', + questionnaireName: + questionnaire?['questionnaireName']?.toString() ?? '', + isEvaluated: item['isEvaluated']?.toString() == '是', + ); + } catch (e) { + return null; + } + } + + /// 访问评价页面并返回HTML内容 + Future accessEvaluationPage( + Course course, { + int? totalCourses, + }) async { + try { + print('📝 Accessing evaluation page for: ${course.name}'); + + if (_token == null) { + print('📝 Getting token first...'); + await getToken(); + print('📝 Token: $_token'); + } + + // count is the total number of courses in the course list + final count = totalCourses?.toString() ?? '28'; + print('📝 Using count: $count (total courses: $totalCourses)'); + + print('📝 Posting to evaluation page...'); + final response = await _client.post( + 'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/evaluationPage', + data: { + 'count': count, + 'evaluatedPeople': course.evaluatedPeople, + 'evaluatedPeopleNumber': course.evaluatedPeopleNumber, + 'questionnaireCode': course.questionnaireCode, + 'questionnaireName': course.questionnaireName, + 'coureSequenceNumber': course.coureSequenceNumber, + 'evaluationContentNumber': course.evaluationContentNumber, + 'evaluationContentContent': '', + 'tokenValue': _token ?? '', + }, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + + print('📝 Access evaluation page status: ${response.statusCode}'); + + if (response.statusCode == 200) { + final html = response.data.toString(); + print('📝 Got HTML content, length: ${html.length}'); + return html; + } + + return null; + } catch (e, stackTrace) { + print('❌ accessEvaluationPage error: $e'); + print('❌ Stack trace: $stackTrace'); + return null; + } + } + + /// 提交评价表单 + Future submitEvaluation( + Map formData, + ) async { + try { + print('📤 Submitting evaluation with form data:'); + print('📤 Form data entries: ${formData.length}'); + formData.forEach((key, value) { + print( + ' $key = ${value.length > 50 ? value.substring(0, 50) + "..." : value}', + ); + }); + + final response = await _client.post( + 'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/assessment?sf_request_type=ajax', + data: formData, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + + print('📤 Submit response status: ${response.statusCode}'); + print('📤 Submit response data: ${response.data}'); + + if (response.statusCode != 200) { + return EvaluationResponse( + result: 'error', + msg: '网络请求失败 (${response.statusCode})', + ); + } + + final data = response.data; + if (data is Map) { + return EvaluationResponse( + result: data['result']?.toString() ?? 'error', + msg: data['msg']?.toString() ?? '未知错误', + ); + } + + return EvaluationResponse(result: 'error', msg: '响应格式错误'); + } catch (e) { + print('❌ Submit evaluation error: $e'); + return EvaluationResponse(result: 'error', msg: '请求异常: $e'); + } + } +} + +/// 评价响应 +class EvaluationResponse { + final String result; + final String msg; + + EvaluationResponse({required this.result, required this.msg}); + + bool get isSuccess => result == 'success'; +} diff --git a/lib/services/evaluation_service.dart b/lib/services/evaluation_service.dart new file mode 100644 index 0000000..db47cbc --- /dev/null +++ b/lib/services/evaluation_service.dart @@ -0,0 +1,431 @@ +import 'dart:math'; +import '../models/course.dart'; +import '../models/questionnaire.dart'; +import '../models/evaluation_result.dart'; +import 'aufe_connection.dart'; +import 'questionnaire_parser.dart'; +import '../utils/text_generator.dart'; + +/// Service for handling course evaluation operations +/// Manages the evaluation process including form data building, +/// option selection, and text generation +class EvaluationService { + final AUFEConnection _connection; + final QuestionnaireParser _parser; + final TextGenerator _textGenerator; + final Random _random = Random(); + + EvaluationService({ + required AUFEConnection connection, + required QuestionnaireParser parser, + required TextGenerator textGenerator, + }) : _connection = connection, + _parser = parser, + _textGenerator = textGenerator; + + /// Prepare evaluation for a course (access page, parse questionnaire, generate answers) + /// + /// [course] - The course to evaluate + /// [totalCourses] - Total number of courses (for count parameter) + /// Returns form data map if successful, null otherwise + Future?> prepareEvaluation( + Course course, { + int? totalCourses, + }) async { + try { + // 1. Access evaluation page to get questionnaire HTML + final html = await _connection.accessEvaluationPage( + course, + totalCourses: totalCourses, + ); + if (html == null) { + return null; + } + + // 2. Parse questionnaire + final questionnaire = await _parser.parseAsync(html); + + // 3. Build form data + final formData = _buildFormData( + questionnaire, + course: course, + totalCourses: totalCourses, + ); + + return formData; + } catch (e, stackTrace) { + print('❌ prepareEvaluation error: $e'); + print('❌ Stack trace: $stackTrace'); + return null; + } + } + + /// Submit evaluation with prepared form data + /// + /// [course] - The course being evaluated + /// [formData] - Prepared form data from prepareEvaluation + /// Returns [EvaluationResult] with success status and error message if failed + Future submitEvaluation( + Course course, + Map formData, + ) async { + try { + final submitResponse = await _connection.submitEvaluation(formData); + + if (submitResponse.isSuccess) { + return EvaluationResult(course: course, success: true); + } else { + return EvaluationResult( + course: course, + success: false, + errorMessage: submitResponse.msg, + ); + } + } catch (e, stackTrace) { + print('❌ submitEvaluation error: $e'); + print('❌ Stack trace: $stackTrace'); + return EvaluationResult( + course: course, + success: false, + errorMessage: '提交评价出错: $e', + ); + } + } + + /// Evaluate a single course (legacy method, combines prepare and submit with countdown) + /// + /// [course] - The course to evaluate + /// [totalCourses] - Total number of courses (for count parameter) + /// [onStatusChange] - Optional callback to report status changes during evaluation + /// [onCountdown] - Optional callback to report countdown progress (seconds remaining, total seconds) + /// Returns [EvaluationResult] with success status and error message if failed + Future evaluateCourse( + Course course, { + int? totalCourses, + Function(String status)? onStatusChange, + Function(int remaining, int total)? onCountdown, + }) async { + try { + // 1. Prepare evaluation + onStatusChange?.call('正在访问评价页面...'); + final formData = await prepareEvaluation( + course, + totalCourses: totalCourses, + ); + if (formData == null) { + return EvaluationResult( + course: course, + success: false, + errorMessage: '无法访问评价页面', + ); + } + + onStatusChange?.call('正在解析问卷结构...'); + onStatusChange?.call('正在生成答案...'); + + // 2. Wait 140 seconds before submission (server anti-bot requirement) + onStatusChange?.call('等待提交中...'); + + const totalSeconds = 140; + for (int i = totalSeconds; i > 0; i--) { + onCountdown?.call(i, totalSeconds); + + // Also report to status for logging + final progress = (totalSeconds - i) / totalSeconds; + final barLength = 20; + final filledLength = (progress * barLength).round(); + final bar = '█' * filledLength + '░' * (barLength - filledLength); + final percent = (progress * 100).toInt(); + onStatusChange?.call('等待提交 [$bar] $percent% (${i}s)'); + + await Future.delayed(const Duration(seconds: 1)); + } + + // 3. Submit evaluation + onStatusChange?.call('正在提交评价...'); + final result = await submitEvaluation(course, formData); + + if (result.success) { + onStatusChange?.call('提交成功'); + } else { + onStatusChange?.call('提交失败: ${result.errorMessage}'); + } + + return result; + } catch (e, stackTrace) { + print('❌ evaluateCourse error: $e'); + print('❌ Stack trace: $stackTrace'); + onStatusChange?.call('评教失败'); + return EvaluationResult( + course: course, + success: false, + errorMessage: '评教过程出错: $e', + ); + } + } + + /// Build form data from questionnaire + /// + /// Automatically selects best options for radio questions + /// and generates appropriate text for text questions + Map _buildFormData( + Questionnaire questionnaire, { + required Course course, + int? totalCourses, + }) { + final formData = {}; + + // Add required metadata fields (matching the correct request format) + // Use Course data as primary source, fallback to questionnaire parsed data + formData['optType'] = 'submit'; + formData['tokenValue'] = questionnaire.tokenValue; + formData['questionnaireCode'] = course.questionnaireCode.isNotEmpty + ? course.questionnaireCode + : questionnaire.questionnaireCode; + formData['evaluationContent'] = course.evaluationContentNumber.isNotEmpty + ? course.evaluationContentNumber + : questionnaire.evaluationContent; + formData['evaluatedPeopleNumber'] = course.evaluatedPeopleNumber.isNotEmpty + ? course.evaluatedPeopleNumber + : questionnaire.evaluatedPeopleNumber; + formData['count'] = totalCourses?.toString() ?? ''; + + // Process radio questions - select best option for each + for (final question in questionnaire.radioQuestions) { + final selectedOption = _selectBestOption(question.options); + formData[question.key] = selectedOption.value; + } + + // Process text questions - generate appropriate text + for (final question in questionnaire.textQuestions) { + final generatedText = _generateTextAnswer(question); + formData[question.key] = generatedText; + } + + print('📝 Form data keys: ${formData.keys.join(", ")}'); + print('📝 Metadata fields:'); + print(' optType = ${formData['optType']}'); + print(' tokenValue = ${formData['tokenValue']}'); + print(' questionnaireCode = ${formData['questionnaireCode']}'); + print(' evaluationContent = ${formData['evaluationContent']}'); + print(' evaluatedPeopleNumber = ${formData['evaluatedPeopleNumber']}'); + print(' count = ${formData['count']}'); + print( + '📝 Question answers (${questionnaire.radioQuestions.length} radio + ${questionnaire.textQuestions.length} text):', + ); + formData.forEach((key, value) { + if (key.startsWith('0000') || key == 'zgpj') { + print(' $key = $value'); + } + }); + + return formData; + } + + /// Select the best option from a list of radio options + /// + /// Strategy: Prefer weight 1.0 options (80% probability) + /// Otherwise select second highest weight option (20% probability) + /// + /// [options] - List of available radio options + /// Returns the selected [RadioOption] + RadioOption _selectBestOption(List options) { + if (options.isEmpty) { + throw ArgumentError('Options list cannot be empty'); + } + + // Sort options by weight in descending order + final sortedOptions = List.from(options) + ..sort((a, b) => b.weight.compareTo(a.weight)); + + // Find options with weight 1.0 + final weight1Options = sortedOptions + .where((opt) => opt.weight == 1.0) + .toList(); + + // If there are weight 1.0 options + if (weight1Options.isNotEmpty) { + // 80% chance to select weight 1.0 option + if (_random.nextDouble() < 0.8) { + // If multiple weight 1.0 options, randomly select one + return weight1Options[_random.nextInt(weight1Options.length)]; + } + } + + // 20% chance or no weight 1.0 options: select second highest weight + if (sortedOptions.length > 1) { + // Find second highest weight + final highestWeight = sortedOptions[0].weight; + final secondHighestOptions = sortedOptions + .where((opt) => opt.weight < highestWeight) + .toList(); + + if (secondHighestOptions.isNotEmpty) { + // Get all options with second highest weight + final secondHighestWeight = secondHighestOptions[0].weight; + final secondHighestGroup = secondHighestOptions + .where((opt) => opt.weight == secondHighestWeight) + .toList(); + + return secondHighestGroup[_random.nextInt(secondHighestGroup.length)]; + } + } + + // Fallback: return highest weight option + return sortedOptions[0]; + } + + /// Generate text answer for a text question + /// + /// Uses TextGenerator to create appropriate text based on question type + /// Validates the generated text meets all requirements + /// + /// [question] - The text question to answer + /// Returns generated text string + String _generateTextAnswer(TextQuestion question) { + // Generate text based on question type + String text = _textGenerator.generate(question.type); + + // Validate the generated text + // If validation fails, try again (up to 3 attempts) + int attempts = 0; + while (!_textGenerator.validate(text) && attempts < 3) { + text = _textGenerator.generate(question.type); + attempts++; + } + + return text; + } + + /// Batch evaluate all pending courses + /// + /// [onProgress] - Callback function to report progress with detailed status + /// Returns [BatchEvaluationResult] with statistics and individual results + Future batchEvaluate({ + required Function(int current, int total, Course course, String status) + onProgress, + }) async { + final startTime = DateTime.now(); + final results = []; + + try { + // 1. Fetch all pending courses + final courses = await _connection.fetchCourseList(); + + // Filter out already evaluated courses + final pendingCourses = courses.where((c) => !c.isEvaluated).toList(); + final total = pendingCourses.length; + + if (total == 0) { + return BatchEvaluationResult( + total: 0, + success: 0, + failed: 0, + results: [], + duration: DateTime.now().difference(startTime), + ); + } + + // 2. Evaluate each course + for (int i = 0; i < pendingCourses.length; i++) { + final course = pendingCourses[i]; + + // Evaluate the course with status updates + final result = await evaluateCourse( + course, + totalCourses: total, + onStatusChange: (status) { + // Report progress with current course and status + // Use i (not i+1) to show completed count, current course is still in progress + onProgress(i, total, course, status); + }, + ); + results.add(result); + + // Check if evaluation failed + if (!result.success) { + // Report failure + onProgress(i + 1, total, course, '评教失败,任务中断'); + + // Stop batch evaluation on first error + final successCount = results.where((r) => r.success).length; + final failedCount = results.where((r) => !r.success).length; + final duration = DateTime.now().difference(startTime); + + return BatchEvaluationResult( + total: i + 1, // Only count evaluated courses + success: successCount, + failed: failedCount, + results: results, + duration: duration, + ); + } + + // Verify evaluation by checking course list + onProgress(i + 1, total, course, '验证评教结果'); + final updatedCourses = await _connection.fetchCourseList(); + final updatedCourse = updatedCourses.firstWhere( + (c) => c.id == course.id, + orElse: () => course, + ); + + if (!updatedCourse.isEvaluated) { + // Evaluation not confirmed, treat as failure + results[results.length - 1] = EvaluationResult( + course: course, + success: false, + errorMessage: '评教未生效,服务器未确认', + ); + + onProgress(i + 1, total, course, '评教未生效,任务中断'); + + final successCount = results.where((r) => r.success).length; + final failedCount = results.where((r) => !r.success).length; + final duration = DateTime.now().difference(startTime); + + return BatchEvaluationResult( + total: i + 1, + success: successCount, + failed: failedCount, + results: results, + duration: duration, + ); + } + + // Report successful completion + onProgress(i + 1, total, course, '评教完成'); + + // Small delay between evaluations to avoid overwhelming the server + if (i < pendingCourses.length - 1) { + await Future.delayed(const Duration(milliseconds: 500)); + } + } + + // 3. Calculate statistics + final successCount = results.where((r) => r.success).length; + final failedCount = results.where((r) => !r.success).length; + final duration = DateTime.now().difference(startTime); + + return BatchEvaluationResult( + total: total, + success: successCount, + failed: failedCount, + results: results, + duration: duration, + ); + } catch (e) { + // If batch evaluation fails completely, return partial results + final successCount = results.where((r) => r.success).length; + final failedCount = results.where((r) => !r.success).length; + final duration = DateTime.now().difference(startTime); + + return BatchEvaluationResult( + total: results.length, + success: successCount, + failed: failedCount, + results: results, + duration: duration, + ); + } + } +} diff --git a/lib/services/http_client.dart b/lib/services/http_client.dart new file mode 100644 index 0000000..1f35e20 --- /dev/null +++ b/lib/services/http_client.dart @@ -0,0 +1,143 @@ +import 'package:dio/dio.dart'; + +/// HTTP客户端封装类,提供统一的网络请求接口 +class HTTPClient { + late Dio _dio; + final Map _cookies = {}; + + HTTPClient({String? baseUrl, int timeout = 30000}) { + _dio = Dio( + BaseOptions( + baseUrl: baseUrl ?? '', + connectTimeout: Duration(milliseconds: timeout), + receiveTimeout: Duration(milliseconds: timeout), + sendTimeout: Duration(milliseconds: timeout), + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + }, + validateStatus: (status) => status != null && status < 500, + ), + ); + + // 添加拦截器用于Cookie管理和日志 + _dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + // 添加Cookie到请求头 + if (_cookies.isNotEmpty) { + final cookieStr = _cookies.entries + .map((e) => '${e.key}=${e.value}') + .join('; '); + options.headers['Cookie'] = cookieStr; + } + + // 打印请求信息 + print('🌐 ${options.method} ${options.uri}'); + + return handler.next(options); + }, + onResponse: (response, handler) { + // 从响应中提取Cookie + final setCookie = response.headers['set-cookie']; + if (setCookie != null) { + for (var cookie in setCookie) { + _parseCookie(cookie); + } + } + + final statusCode = response.statusCode ?? 0; + + // 如果状态码 >= 400,打印详细错误信息 + if (statusCode >= 400) { + print( + '❌ Response Error: $statusCode ${response.requestOptions.uri}', + ); + print('❌ Response Headers: ${response.headers}'); + print('❌ Response Data: ${response.data}'); + } else { + // 正常响应只打印状态码 + print('✅ $statusCode ${response.requestOptions.uri}'); + } + + return handler.next(response); + }, + onError: (error, handler) { + print('❌ HTTP Error: ${error.message}'); + print('❌ Error type: ${error.type}'); + print( + '❌ Request: ${error.requestOptions.method} ${error.requestOptions.uri}', + ); + + if (error.response != null) { + print('❌ Status code: ${error.response?.statusCode}'); + print('❌ Response Headers: ${error.response?.headers}'); + print('❌ Response Data: ${error.response?.data}'); + } + + return handler.next(error); + }, + ), + ); + } + + /// 解析Cookie字符串并存储 + void _parseCookie(String cookieStr) { + final parts = cookieStr.split(';')[0].split('='); + if (parts.length == 2) { + _cookies[parts[0].trim()] = parts[1].trim(); + } + } + + /// GET请求 + Future get( + String path, { + Map? params, + Options? options, + }) async { + return await _dio.get(path, queryParameters: params, options: options); + } + + /// POST请求 + Future post( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + }) async { + return await _dio.post( + path, + data: data, + queryParameters: queryParameters, + options: options, + ); + } + + /// 设置Cookie + void setCookie(String name, String value) { + _cookies[name] = value; + } + + /// 获取Cookie + String? getCookie(String name) { + return _cookies[name]; + } + + /// 获取所有Cookie + Map getAllCookies() { + return Map.from(_cookies); + } + + /// 清除所有Cookie + void clearCookies() { + _cookies.clear(); + } + + /// 关闭客户端 + void close() { + _dio.close(); + } + + /// 获取Dio实例(用于高级操作) + Dio get dio => _dio; +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..eb7772e --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,203 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter/foundation.dart'; + +/// Service for managing local notifications +/// Handles batch evaluation progress notifications and completion alerts +/// +/// Usage example: +/// ```dart +/// final notificationService = NotificationService(); +/// await notificationService.initialize(); +/// +/// // Set up tap callback +/// notificationService.onNotificationTapped = (payload) { +/// // Handle navigation based on payload +/// if (payload == 'batch_complete') { +/// // Navigate to results screen +/// } +/// }; +/// +/// // Show batch start notification +/// await notificationService.showBatchStartNotification(10); +/// +/// // Update progress +/// await notificationService.updateProgressNotification( +/// current: 5, +/// total: 10, +/// courseName: '高等数学', +/// ); +/// +/// // Show completion +/// await notificationService.showCompletionNotification( +/// success: 9, +/// failed: 1, +/// total: 10, +/// ); +/// ``` +class NotificationService { + static const String _channelId = 'evaluation_progress'; + static const String _channelName = '评教进度'; + static const String _channelDescription = '显示批量评教的实时进度'; + + static const int _batchNotificationId = 1000; + static const int _progressNotificationId = 1001; + static const int _completionNotificationId = 1002; + static const int _errorNotificationId = 1003; + + final FlutterLocalNotificationsPlugin _notifications; + bool _isInitialized = false; + + // Callback for when notification is tapped + Function(String?)? onNotificationTapped; + + NotificationService() : _notifications = FlutterLocalNotificationsPlugin(); + + /// Initialize the notification service + /// + /// Configures notification channels for Android and iOS + /// Sets up notification icons and default settings + /// + /// Returns true if initialization succeeds, false otherwise + Future initialize() async { + if (_isInitialized) { + return true; + } + + try { + // Android initialization settings + const androidSettings = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); + + // iOS initialization settings + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + + // Combined initialization settings + const initSettings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + + // Initialize the plugin + final initialized = await _notifications.initialize( + initSettings, + onDidReceiveNotificationResponse: _onNotificationTapped, + onDidReceiveBackgroundNotificationResponse: _onNotificationTapped, + ); + + if (initialized == true) { + // Create notification channel for Android + await _createNotificationChannel(); + _isInitialized = true; + return true; + } + + return false; + } catch (e) { + debugPrint('Failed to initialize notification service: $e'); + return false; + } + } + + /// Create Android notification channel + /// + /// Sets up a high-priority channel for evaluation progress notifications + Future _createNotificationChannel() async { + const androidChannel = AndroidNotificationChannel( + _channelId, + _channelName, + description: _channelDescription, + importance: Importance.high, + enableVibration: true, + playSound: true, + ); + + await _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(androidChannel); + } + + /// Handle notification tap events + /// + /// Called when user taps on a notification + /// Triggers the callback to allow navigation to specific screens + void _onNotificationTapped(NotificationResponse response) { + debugPrint('Notification tapped: ${response.payload}'); + + // Trigger the callback if set + if (onNotificationTapped != null) { + onNotificationTapped!(response.payload); + } + } + + /// Check if the service is initialized + bool get isInitialized => _isInitialized; + + /// Show notification when batch evaluation starts + /// + /// [totalCourses] - Total number of courses to evaluate + Future showBatchStartNotification(int totalCourses) async { + // Notification service disabled + return; + } + + /// Update progress notification with current status + /// + /// Shows a progress bar and current course being evaluated + /// + /// [current] - Number of courses completed + /// [total] - Total number of courses + /// [courseName] - Name of the current course being evaluated + Future updateProgressNotification({ + required int current, + required int total, + required String courseName, + }) async { + // Notification service disabled + return; + } + + /// Show completion notification with final statistics + /// + /// [success] - Number of successfully evaluated courses + /// [failed] - Number of failed evaluations + /// [total] - Total number of courses + Future showCompletionNotification({ + required int success, + required int failed, + required int total, + }) async { + // Notification service disabled + return; + } + + /// Show error notification + /// + /// [message] - Error message to display + Future showErrorNotification(String message) async { + // Notification service disabled + return; + } + + /// Cancel all active notifications + /// + /// Clears all notifications from the notification tray + Future cancelAll() async { + if (!_isInitialized) { + debugPrint('Notification service not initialized'); + return; + } + + try { + await _notifications.cancelAll(); + } catch (e) { + debugPrint('Failed to cancel notifications: $e'); + } + } +} diff --git a/lib/services/questionnaire_parser.dart b/lib/services/questionnaire_parser.dart new file mode 100644 index 0000000..d763d0e --- /dev/null +++ b/lib/services/questionnaire_parser.dart @@ -0,0 +1,412 @@ +import 'package:flutter/foundation.dart'; +import 'package:html/parser.dart' as html_parser; +import 'package:html/dom.dart'; +import '../models/questionnaire.dart'; + +/// Parser for HTML questionnaire documents +/// Dynamically extracts questionnaire structure including radio questions, +/// text questions, and metadata +class QuestionnaireParser { + // Cache for parsed questionnaires + static final Map _cache = {}; + + /// Parse HTML in isolate for better performance + /// + /// [htmlContent] - The HTML content of the questionnaire page + /// [useCache] - Whether to use cached results (default: true) + /// Returns a [Questionnaire] object containing all parsed data + Future parseAsync( + String htmlContent, { + bool useCache = true, + }) async { + // Generate cache key from content hash + final cacheKey = htmlContent.hashCode.toString(); + + // Check cache first + if (useCache && _cache.containsKey(cacheKey)) { + return _cache[cacheKey]!; + } + + // Parse in isolate to avoid blocking UI thread + final questionnaire = await compute(_parseInIsolate, htmlContent); + + // Store in cache + if (useCache) { + _cache[cacheKey] = questionnaire; + + // Limit cache size to prevent memory issues + if (_cache.length > 50) { + // Remove oldest entries (simple FIFO) + final keysToRemove = _cache.keys.take(_cache.length - 50).toList(); + for (var key in keysToRemove) { + _cache.remove(key); + } + } + } + + return questionnaire; + } + + /// Clear the parser cache + static void clearCache() { + _cache.clear(); + } + + /// Static method for isolate parsing + static Questionnaire _parseInIsolate(String htmlContent) { + final parser = QuestionnaireParser(); + return parser.parse(htmlContent); + } + + /// Parse HTML document and extract questionnaire structure + /// + /// [htmlContent] - The HTML content of the questionnaire page + /// Returns a [Questionnaire] object containing all parsed data + Questionnaire parse(String htmlContent) { + final document = html_parser.parse(htmlContent); + + // Extract metadata first + final metadata = _extractMetadata(document); + + // Extract radio questions (single-choice questions) + final radioQuestions = _extractRadioQuestions(document); + + // Extract text questions (open-ended questions) + final textQuestions = _extractTextQuestions(document); + + return Questionnaire( + metadata: metadata, + radioQuestions: radioQuestions, + textQuestions: textQuestions, + tokenValue: metadata.tokenValue, + questionnaireCode: metadata.questionnaireCode, + evaluationContent: metadata.evaluationContent, + evaluatedPeopleNumber: metadata.evaluatedPeopleNumber, + ); + } + + /// Extract questionnaire metadata from HTML document + /// + /// Extracts: + /// - Title (questionnaire title) + /// - Evaluated person (teacher name) + /// - Evaluation content + /// - Token value (CSRF token) + /// - Questionnaire code + /// - Evaluated people number + QuestionnaireMetadata _extractMetadata(Document document) { + String title = ''; + String evaluatedPerson = ''; + String evaluationContent = ''; + String tokenValue = ''; + String questionnaireCode = ''; + String evaluatedPeopleNumber = ''; + + // Extract title - usually in a specific div or h1/h2 tag + final titleElement = + document.querySelector('div.title') ?? + document.querySelector('h1') ?? + document.querySelector('h2'); + if (titleElement != null) { + title = titleElement.text.trim(); + } + + // Extract token value from hidden input + final tokenInput = document.querySelector('input[name="tokenValue"]'); + if (tokenInput != null) { + tokenValue = tokenInput.attributes['value'] ?? ''; + } + + // Extract questionnaire code from hidden input + final codeInput = document.querySelector('input[name="wjdm"]'); + if (codeInput != null) { + questionnaireCode = codeInput.attributes['value'] ?? ''; + } + + // Extract evaluated people number from hidden input + final peopleNumberInput = document.querySelector('input[name="bprdm"]'); + if (peopleNumberInput != null) { + evaluatedPeopleNumber = peopleNumberInput.attributes['value'] ?? ''; + } + + // Extract evaluation content from hidden input + final contentInput = document.querySelector('input[name="pgnr"]'); + if (contentInput != null) { + evaluationContent = contentInput.attributes['value'] ?? ''; + } + + // Try to extract evaluated person name from table or specific elements + // Look for teacher name in common patterns + final teacherElements = document.querySelectorAll('td'); + for (var element in teacherElements) { + final text = element.text.trim(); + if (text.contains('被评人') || text.contains('教师')) { + // Get the next sibling or adjacent cell + final nextSibling = element.nextElementSibling; + if (nextSibling != null) { + evaluatedPerson = nextSibling.text.trim(); + break; + } + } + } + + return QuestionnaireMetadata( + title: title, + evaluatedPerson: evaluatedPerson, + evaluationContent: evaluationContent, + tokenValue: tokenValue, + questionnaireCode: questionnaireCode, + evaluatedPeopleNumber: evaluatedPeopleNumber, + ); + } + + /// Extract all radio questions from the document + /// + /// Parses all input[type="radio"] elements and groups them by name attribute + /// Extracts score and weight from value attribute (format: "score_weight") + List _extractRadioQuestions(Document document) { + final Map questionsMap = {}; + + // Find all radio input elements + final radioInputs = document.querySelectorAll('input[type="radio"]'); + + for (var input in radioInputs) { + final name = input.attributes['name']; + final value = input.attributes['value']; + + if (name == null || value == null || name.isEmpty || value.isEmpty) { + continue; + } + + // Parse value format "score_weight" (e.g., "5_1" means 5 points with 100% weight) + final parts = value.split('_'); + double score = 0.0; + double weight = 0.0; + + if (parts.length >= 2) { + score = double.tryParse(parts[0]) ?? 0.0; + weight = double.tryParse(parts[1]) ?? 0.0; + } + + // Extract option label - look for adjacent label or text + String label = ''; + + // Try to find label element associated with this input + final inputId = input.attributes['id']; + if (inputId != null && inputId.isNotEmpty) { + final labelElement = document.querySelector('label[for="$inputId"]'); + if (labelElement != null) { + label = labelElement.text.trim(); + } + } + + // If no label found, look for parent label + if (label.isEmpty) { + var parent = input.parent; + while (parent != null && parent.localName != 'label') { + parent = parent.parent; + } + if (parent != null && parent.localName == 'label') { + label = parent.text.trim(); + } + } + + // If still no label, look for adjacent text in the same td/cell + if (label.isEmpty) { + var cell = input.parent; + while (cell != null && cell.localName != 'td') { + cell = cell.parent; + } + if (cell != null) { + label = cell.text.trim(); + } + } + + // Create RadioOption + final option = RadioOption( + label: label, + value: value, + score: score, + weight: weight, + ); + + // Extract question text and category + if (!questionsMap.containsKey(name)) { + String questionText = ''; + String category = ''; + + // Find the question text - usually in a td with rowspan or previous row + var row = input.parent; + while (row != null && row.localName != 'tr') { + row = row.parent; + } + + if (row != null) { + // Look for td with rowspan (category indicator) + final categoryCell = row.querySelector('td[rowspan]'); + if (categoryCell != null) { + category = categoryCell.text.trim(); + } + + // Look for question text in the first td or a specific class + final cells = row.querySelectorAll('td'); + for (var cell in cells) { + final text = cell.text.trim(); + // Skip cells that only contain radio buttons or are too short + if (text.isNotEmpty && + !text.contains('input') && + text.length > 5 && + cell.querySelector('input[type="radio"]') == null) { + questionText = text; + break; + } + } + + // If question text not found in current row, check previous rows + if (questionText.isEmpty) { + var prevRow = row.previousElementSibling; + while (prevRow != null) { + final prevCells = prevRow.querySelectorAll('td'); + for (var cell in prevCells) { + final text = cell.text.trim(); + if (text.isNotEmpty && text.length > 5) { + questionText = text; + break; + } + } + if (questionText.isNotEmpty) break; + prevRow = prevRow.previousElementSibling; + } + } + } + + questionsMap[name] = RadioQuestion( + key: name, + questionText: questionText, + options: [option], + category: category, + ); + } else { + // Add option to existing question + final existingQuestion = questionsMap[name]!; + questionsMap[name] = RadioQuestion( + key: existingQuestion.key, + questionText: existingQuestion.questionText, + options: [...existingQuestion.options, option], + category: existingQuestion.category, + ); + } + } + + return questionsMap.values.toList(); + } + + /// Extract all text questions from the document + /// + /// Parses all textarea elements and identifies question types + /// based on surrounding text content + List _extractTextQuestions(Document document) { + final List textQuestions = []; + + // Find all textarea elements + final textareas = document.querySelectorAll('textarea'); + + for (var textarea in textareas) { + final name = textarea.attributes['name']; + + if (name == null || name.isEmpty) { + continue; + } + + // Extract question text from adjacent elements + String questionText = ''; + + // Look for question text in the same row or previous elements + var cell = textarea.parent; + while (cell != null && cell.localName != 'td') { + cell = cell.parent; + } + + if (cell != null) { + // Check previous sibling cells for question text + var prevCell = cell.previousElementSibling; + if (prevCell != null) { + questionText = prevCell.text.trim(); + } + + // If not found, look in the same cell before the textarea + if (questionText.isEmpty) { + final cellText = cell.text.trim(); + if (cellText.isNotEmpty) { + questionText = cellText; + } + } + + // If still not found, look in previous row + if (questionText.isEmpty) { + var row = cell.parent; + if (row != null && row.localName == 'tr') { + var prevRow = row.previousElementSibling; + if (prevRow != null) { + final prevCells = prevRow.querySelectorAll('td'); + for (var prevCell in prevCells) { + final text = prevCell.text.trim(); + if (text.isNotEmpty && text.length > 3) { + questionText = text; + break; + } + } + } + } + } + } + + // Analyze question type based on text content and name + final questionType = _analyzeQuestionType(questionText, name); + + // Determine if required - zgpj is typically required + final isRequired = name == 'zgpj' || name.contains('zgpj'); + + textQuestions.add( + TextQuestion( + key: name, + questionText: questionText, + type: questionType, + isRequired: isRequired, + ), + ); + } + + return textQuestions; + } + + /// Analyze question type based on question text and field name + /// + /// Uses keyword matching to identify: + /// - Inspiration questions (contains "启发") + /// - Suggestion questions (contains "建议" or "意见") + /// - Overall evaluation (name is "zgpj") + /// - General questions (default) + QuestionType _analyzeQuestionType(String questionText, String fieldName) { + // Check field name first + if (fieldName == 'zgpj' || fieldName.contains('zgpj')) { + return QuestionType.overall; + } + + // Check question text for keywords + final lowerText = questionText.toLowerCase(); + + if (lowerText.contains('启发') || lowerText.contains('启示')) { + return QuestionType.inspiration; + } + + if (lowerText.contains('建议') || + lowerText.contains('意见') || + lowerText.contains('改进')) { + return QuestionType.suggestion; + } + + // Default to general type + return QuestionType.general; + } +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart new file mode 100644 index 0000000..c939953 --- /dev/null +++ b/lib/services/storage_service.dart @@ -0,0 +1,325 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/evaluation_history.dart'; + +/// Service for managing local storage using SharedPreferences +/// +/// Handles storage of evaluation history, cache management, +/// and other persistent data (excluding secure credentials) +/// +/// Usage example: +/// ```dart +/// final storageService = StorageService(); +/// +/// // Save evaluation history +/// await storageService.saveEvaluationHistory(history); +/// +/// // Load evaluation history +/// final histories = await storageService.loadEvaluationHistory(); +/// +/// // Clear all data +/// await storageService.clearAllData(); +/// ``` +class StorageService { + static const String _evaluationHistoryKey = 'evaluation_history'; + static const String _lastSyncTimeKey = 'last_sync_time'; + static const String _cacheVersionKey = 'cache_version'; + static const int _maxHistoryItems = 100; // Maximum history items to keep + + /// Save evaluation history to local storage + /// + /// Appends new history item to existing list + /// Automatically trims list if it exceeds max items + /// + /// [history] - The evaluation history to save + Future saveEvaluationHistory(EvaluationHistory history) async { + try { + final prefs = await SharedPreferences.getInstance(); + + // Load existing history + final histories = await loadEvaluationHistory(); + + // Add new history at the beginning + histories.insert(0, history); + + // Trim if exceeds max items + if (histories.length > _maxHistoryItems) { + histories.removeRange(_maxHistoryItems, histories.length); + } + + // Convert to JSON and save + final jsonList = histories.map((h) => h.toJson()).toList(); + final jsonString = jsonEncode(jsonList); + await prefs.setString(_evaluationHistoryKey, jsonString); + } catch (e) { + throw StorageException('Failed to save evaluation history: $e'); + } + } + + /// Save multiple evaluation histories at once + /// + /// Useful for batch operations + /// + /// [histories] - List of evaluation histories to save + Future saveEvaluationHistories( + List histories, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + + // Load existing history + final existingHistories = await loadEvaluationHistory(); + + // Merge new histories at the beginning + final mergedHistories = [...histories, ...existingHistories]; + + // Trim if exceeds max items + final trimmedHistories = mergedHistories.length > _maxHistoryItems + ? mergedHistories.sublist(0, _maxHistoryItems) + : mergedHistories; + + // Convert to JSON and save + final jsonList = trimmedHistories.map((h) => h.toJson()).toList(); + final jsonString = jsonEncode(jsonList); + await prefs.setString(_evaluationHistoryKey, jsonString); + } catch (e) { + throw StorageException('Failed to save evaluation histories: $e'); + } + } + + /// Load evaluation history from local storage + /// + /// Returns list of evaluation histories sorted by timestamp (newest first) + /// Returns empty list if no history exists + Future> loadEvaluationHistory() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_evaluationHistoryKey); + + if (jsonString == null || jsonString.isEmpty) { + return []; + } + + final jsonList = jsonDecode(jsonString) as List; + final histories = jsonList + .map( + (json) => EvaluationHistory.fromJson(json as Map), + ) + .toList(); + + // Sort by timestamp (newest first) + histories.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + return histories; + } catch (e) { + throw StorageException('Failed to load evaluation history: $e'); + } + } + + /// Get evaluation history for a specific course + /// + /// [courseId] - The course ID to filter by + /// Returns list of histories for the specified course + Future> getHistoryByCourse(String courseId) async { + try { + final allHistories = await loadEvaluationHistory(); + return allHistories.where((h) => h.course.id == courseId).toList(); + } catch (e) { + throw StorageException('Failed to get history by course: $e'); + } + } + + /// Get successful evaluation count + /// + /// Returns the total number of successful evaluations + Future getSuccessfulEvaluationCount() async { + try { + final histories = await loadEvaluationHistory(); + return histories.where((h) => h.success).length; + } catch (e) { + throw StorageException('Failed to get successful evaluation count: $e'); + } + } + + /// Get failed evaluation count + /// + /// Returns the total number of failed evaluations + Future getFailedEvaluationCount() async { + try { + final histories = await loadEvaluationHistory(); + return histories.where((h) => !h.success).length; + } catch (e) { + throw StorageException('Failed to get failed evaluation count: $e'); + } + } + + /// Clear evaluation history + /// + /// Removes all stored evaluation history + Future clearEvaluationHistory() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_evaluationHistoryKey); + } catch (e) { + throw StorageException('Failed to clear evaluation history: $e'); + } + } + + /// Save last sync time + /// + /// Records when the last data synchronization occurred + /// + /// [time] - The timestamp to save + Future saveLastSyncTime(DateTime time) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_lastSyncTimeKey, time.toIso8601String()); + } catch (e) { + throw StorageException('Failed to save last sync time: $e'); + } + } + + /// Load last sync time + /// + /// Returns the timestamp of the last synchronization + /// Returns null if no sync has occurred + Future loadLastSyncTime() async { + try { + final prefs = await SharedPreferences.getInstance(); + final timeString = prefs.getString(_lastSyncTimeKey); + + if (timeString == null) { + return null; + } + + return DateTime.parse(timeString); + } catch (e) { + throw StorageException('Failed to load last sync time: $e'); + } + } + + /// Get cache version + /// + /// Returns the current cache version number + /// Used for cache invalidation when data structure changes + Future getCacheVersion() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_cacheVersionKey) ?? 1; + } catch (e) { + throw StorageException('Failed to get cache version: $e'); + } + } + + /// Set cache version + /// + /// Updates the cache version number + /// + /// [version] - The new version number + Future setCacheVersion(int version) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_cacheVersionKey, version); + } catch (e) { + throw StorageException('Failed to set cache version: $e'); + } + } + + /// Clear cache if version mismatch + /// + /// Compares current cache version with expected version + /// Clears cache if they don't match + /// + /// [expectedVersion] - The expected cache version + /// Returns true if cache was cleared, false otherwise + Future clearCacheIfVersionMismatch(int expectedVersion) async { + try { + final currentVersion = await getCacheVersion(); + + if (currentVersion != expectedVersion) { + await clearEvaluationHistory(); + await setCacheVersion(expectedVersion); + return true; + } + + return false; + } catch (e) { + throw StorageException('Failed to check cache version: $e'); + } + } + + /// Clear all local data + /// + /// Removes all data stored by this service + /// Does NOT clear secure storage (credentials) + /// Does NOT clear theme preferences + Future clearAllData() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_evaluationHistoryKey); + await prefs.remove(_lastSyncTimeKey); + // Keep cache version to maintain compatibility + } catch (e) { + throw StorageException('Failed to clear all data: $e'); + } + } + + /// Get storage statistics + /// + /// Returns information about stored data + Future getStorageStats() async { + try { + final histories = await loadEvaluationHistory(); + final lastSync = await loadLastSyncTime(); + final cacheVersion = await getCacheVersion(); + + return StorageStats( + totalHistoryItems: histories.length, + successfulEvaluations: histories.where((h) => h.success).length, + failedEvaluations: histories.where((h) => !h.success).length, + lastSyncTime: lastSync, + cacheVersion: cacheVersion, + ); + } catch (e) { + throw StorageException('Failed to get storage stats: $e'); + } + } +} + +/// Storage statistics data class +class StorageStats { + final int totalHistoryItems; + final int successfulEvaluations; + final int failedEvaluations; + final DateTime? lastSyncTime; + final int cacheVersion; + + StorageStats({ + required this.totalHistoryItems, + required this.successfulEvaluations, + required this.failedEvaluations, + this.lastSyncTime, + required this.cacheVersion, + }); + + @override + String toString() { + return 'StorageStats(' + 'total: $totalHistoryItems, ' + 'success: $successfulEvaluations, ' + 'failed: $failedEvaluations, ' + 'lastSync: $lastSyncTime, ' + 'cacheVersion: $cacheVersion' + ')'; + } +} + +/// Exception thrown when storage operations fail +class StorageException implements Exception { + final String message; + + StorageException(this.message); + + @override + String toString() => 'StorageException: $message'; +} diff --git a/lib/utils/.gitkeep b/lib/utils/.gitkeep new file mode 100644 index 0000000..d71da9f --- /dev/null +++ b/lib/utils/.gitkeep @@ -0,0 +1,2 @@ +# Utils directory +# This directory contains utility functions and helpers diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart new file mode 100644 index 0000000..aa90492 --- /dev/null +++ b/lib/utils/app_logger.dart @@ -0,0 +1,239 @@ +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; + +/// Application-wide logger with configurable levels and output +class AppLogger { + static final AppLogger _instance = AppLogger._internal(); + factory AppLogger() => _instance; + + late final Logger _logger; + bool _isInitialized = false; + + AppLogger._internal(); + + /// Initialize the logger with appropriate settings + void initialize({Level? level, LogOutput? output, LogFilter? filter}) { + if (_isInitialized) return; + + _logger = Logger( + level: level ?? _getDefaultLevel(), + filter: filter ?? ProductionFilter(), + printer: _createPrinter(), + output: output ?? _createOutput(), + ); + + _isInitialized = true; + } + + /// Get default log level based on build mode + Level _getDefaultLevel() { + if (kDebugMode) { + return Level.debug; + } else if (kProfileMode) { + return Level.info; + } else { + return Level.warning; + } + } + + /// Create appropriate printer based on build mode + LogPrinter _createPrinter() { + if (kDebugMode) { + // Detailed printer for development + return PrettyPrinter( + methodCount: 2, + errorMethodCount: 8, + lineLength: 120, + colors: true, + printEmojis: true, + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, + ); + } else { + // Simple printer for production + return SimplePrinter(colors: false); + } + } + + /// Create appropriate output based on build mode + LogOutput _createOutput() { + if (kReleaseMode) { + // In production, you might want to send logs to a file or remote service + return ConsoleOutput(); + } else { + return ConsoleOutput(); + } + } + + /// Ensure logger is initialized + void _ensureInitialized() { + if (!_isInitialized) { + initialize(); + } + } + + /// Log a trace message (most verbose) + void trace(String message, {Object? error, StackTrace? stackTrace}) { + _ensureInitialized(); + _logger.t(message, error: error, stackTrace: stackTrace); + } + + /// Log a debug message + void debug(String message, {Object? error, StackTrace? stackTrace}) { + _ensureInitialized(); + _logger.d(message, error: error, stackTrace: stackTrace); + } + + /// Log an info message + void info(String message, {Object? error, StackTrace? stackTrace}) { + _ensureInitialized(); + _logger.i(message, error: error, stackTrace: stackTrace); + } + + /// Log a warning message + void warning(String message, {Object? error, StackTrace? stackTrace}) { + _ensureInitialized(); + _logger.w(message, error: error, stackTrace: stackTrace); + } + + /// Log an error message + void error(String message, {Object? error, StackTrace? stackTrace}) { + _ensureInitialized(); + _logger.e(message, error: error, stackTrace: stackTrace); + } + + /// Log a fatal error message + void fatal(String message, {Object? error, StackTrace? stackTrace}) { + _ensureInitialized(); + _logger.f(message, error: error, stackTrace: stackTrace); + } + + /// Log network request + void logRequest( + String method, + String url, { + Map? headers, + dynamic body, + }) { + if (!kReleaseMode) { + _ensureInitialized(); + final buffer = StringBuffer(); + buffer.writeln('→ $method $url'); + + if (headers != null && headers.isNotEmpty) { + buffer.writeln('Headers: ${_sanitizeHeaders(headers)}'); + } + + if (body != null) { + buffer.writeln('Body: ${_sanitizeBody(body)}'); + } + + _logger.d(buffer.toString()); + } + } + + /// Log network response + void logResponse( + String method, + String url, + int statusCode, { + dynamic body, + Duration? duration, + }) { + if (!kReleaseMode) { + _ensureInitialized(); + final buffer = StringBuffer(); + buffer.write('← $method $url [$statusCode]'); + + if (duration != null) { + buffer.write(' (${duration.inMilliseconds}ms)'); + } + buffer.writeln(); + + if (body != null) { + buffer.writeln('Body: ${_sanitizeBody(body)}'); + } + + if (statusCode >= 200 && statusCode < 300) { + _logger.d(buffer.toString()); + } else if (statusCode >= 400) { + _logger.e(buffer.toString()); + } else { + _logger.w(buffer.toString()); + } + } + } + + /// Sanitize headers to remove sensitive information + Map _sanitizeHeaders(Map headers) { + final sanitized = Map.from(headers); + + // List of sensitive header keys to redact + const sensitiveKeys = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + ]; + + for (final key in sensitiveKeys) { + if (sanitized.containsKey(key.toLowerCase())) { + sanitized[key] = '***REDACTED***'; + } + } + + return sanitized; + } + + /// Sanitize body to remove sensitive information + dynamic _sanitizeBody(dynamic body) { + if (body is Map) { + final sanitized = Map.from(body); + + // List of sensitive field names to redact + const sensitiveFields = [ + 'password', + 'pwd', + 'passwd', + 'token', + 'secret', + 'apiKey', + 'api_key', + ]; + + for (final field in sensitiveFields) { + if (sanitized.containsKey(field)) { + sanitized[field] = '***REDACTED***'; + } + } + + return sanitized; + } + + // For string bodies, check if it contains sensitive patterns + if (body is String) { + if (body.length > 1000) { + return '${body.substring(0, 1000)}... (truncated)'; + } + + // Redact potential passwords in query strings or form data + return body.replaceAllMapped( + RegExp( + r'(password|pwd|passwd|token|secret)=[^&\s]+', + caseSensitive: false, + ), + (match) => '${match.group(1)}=***REDACTED***', + ); + } + + return body; + } + + /// Close the logger and release resources + void close() { + if (_isInitialized) { + _logger.close(); + _isInitialized = false; + } + } +} diff --git a/lib/utils/error_handler.dart b/lib/utils/error_handler.dart new file mode 100644 index 0000000..bd39cda --- /dev/null +++ b/lib/utils/error_handler.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'app_logger.dart'; +import 'exceptions.dart'; + +/// Global error handler for the application +class ErrorHandler { + static final ErrorHandler _instance = ErrorHandler._internal(); + factory ErrorHandler() => _instance; + ErrorHandler._internal(); + + final AppLogger _logger = AppLogger(); + bool _isInitialized = false; + + /// Initialize global error handling + void initialize() { + if (_isInitialized) return; + + // Capture Flutter framework errors + FlutterError.onError = (FlutterErrorDetails details) { + _handleFlutterError(details); + }; + + // Capture errors in platform-specific code + PlatformDispatcher.instance.onError = (error, stack) { + _handlePlatformError(error, stack); + return true; + }; + + _isInitialized = true; + _logger.info('Global error handler initialized'); + } + + /// Run the app with error zone guarding + static Future runAppWithErrorHandling( + Future Function() appRunner, + ) async { + final errorHandler = ErrorHandler(); + errorHandler.initialize(); + + // Run app in a guarded zone to catch async errors + await runZonedGuarded( + () async { + await appRunner(); + }, + (error, stackTrace) { + errorHandler._handleZoneError(error, stackTrace); + }, + ); + } + + /// Handle Flutter framework errors + void _handleFlutterError(FlutterErrorDetails details) { + // Log the error + _logger.error( + 'Flutter Error', + error: details.exception, + stackTrace: details.stack, + ); + + // In debug mode, show the red error screen + if (kDebugMode) { + FlutterError.presentError(details); + } + + // Optionally report to crash reporting service + _reportError(details.exception, details.stack); + } + + /// Handle platform-specific errors + bool _handlePlatformError(Object error, StackTrace stackTrace) { + _logger.error('Platform Error', error: error, stackTrace: stackTrace); + + _reportError(error, stackTrace); + return true; + } + + /// Handle errors from runZonedGuarded + void _handleZoneError(Object error, StackTrace stackTrace) { + _logger.error('Async Error', error: error, stackTrace: stackTrace); + + _reportError(error, stackTrace); + } + + /// Report error to external service (optional) + void _reportError(Object error, StackTrace? stackTrace) { + // In production, you could send errors to services like: + // - Firebase Crashlytics + // - Sentry + // - Custom error reporting endpoint + + if (kReleaseMode) { + // TODO: Implement error reporting to external service + // Example: FirebaseCrashlytics.instance.recordError(error, stackTrace); + } + } + + /// Handle and display user-friendly error messages + static String getUserFriendlyMessage(Object error) { + if (error is AppException) { + return error.message; + } else if (error is NetworkException) { + return '网络连接失败,请检查网络设置'; + } else if (error is AuthenticationException) { + return '登录失败,请检查账号密码'; + } else if (error is ParseException) { + return '数据解析失败,请稍后重试'; + } else if (error is ValidationException) { + return '输入验证失败:${error.message}'; + } else if (error is TimeoutException) { + return '请求超时,请稍后重试'; + } else if (error is FormatException) { + return '数据格式错误'; + } else { + return '发生未知错误,请稍后重试'; + } + } + + /// Show error dialog to user + static void showErrorDialog( + BuildContext context, + Object error, { + VoidCallback? onRetry, + }) { + final message = getUserFriendlyMessage(error); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('错误'), + content: Text(message), + actions: [ + if (onRetry != null) + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onRetry(); + }, + child: const Text('重试'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('确定'), + ), + ], + ), + ); + } + + /// Show error snackbar to user + static void showErrorSnackBar( + BuildContext context, + Object error, { + Duration duration = const Duration(seconds: 3), + }) { + final message = getUserFriendlyMessage(error); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: duration, + backgroundColor: Colors.red, + action: SnackBarAction( + label: '关闭', + textColor: Colors.white, + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + ), + ); + } +} diff --git a/lib/utils/exceptions.dart b/lib/utils/exceptions.dart new file mode 100644 index 0000000..7c14fb4 --- /dev/null +++ b/lib/utils/exceptions.dart @@ -0,0 +1,148 @@ +/// Base exception class for all application exceptions +abstract class AppException implements Exception { + final String message; + final String? details; + final StackTrace? stackTrace; + + AppException(this.message, [this.details, this.stackTrace]); + + @override + String toString() { + if (details != null) { + return '$runtimeType: $message\nDetails: $details'; + } + return '$runtimeType: $message'; + } +} + +/// Exception thrown when network operations fail +class NetworkException extends AppException { + final int? statusCode; + final String? url; + + NetworkException( + super.message, [ + super.details, + super.stackTrace, + this.statusCode, + this.url, + ]); + + @override + String toString() { + final buffer = StringBuffer('NetworkException: $message'); + if (statusCode != null) { + buffer.write('\nStatus Code: $statusCode'); + } + if (url != null) { + buffer.write('\nURL: $url'); + } + if (details != null) { + buffer.write('\nDetails: $details'); + } + return buffer.toString(); + } +} + +/// Exception thrown when authentication fails +class AuthenticationException extends AppException { + final String? userId; + final AuthenticationFailureReason? reason; + + AuthenticationException( + super.message, [ + super.details, + super.stackTrace, + this.userId, + this.reason, + ]); + + @override + String toString() { + final buffer = StringBuffer('AuthenticationException: $message'); + if (userId != null) { + buffer.write('\nUser ID: $userId'); + } + if (reason != null) { + buffer.write('\nReason: ${reason!.name}'); + } + if (details != null) { + buffer.write('\nDetails: $details'); + } + return buffer.toString(); + } +} + +/// Reasons for authentication failure +enum AuthenticationFailureReason { + invalidCredentials, + sessionExpired, + networkError, + serverError, + unknown, +} + +/// Exception thrown when parsing fails +class ParseException extends AppException { + final String? source; + final String? expectedFormat; + + ParseException( + super.message, [ + super.details, + super.stackTrace, + this.source, + this.expectedFormat, + ]); + + @override + String toString() { + final buffer = StringBuffer('ParseException: $message'); + if (expectedFormat != null) { + buffer.write('\nExpected Format: $expectedFormat'); + } + if (source != null && source!.length <= 100) { + buffer.write('\nSource: $source'); + } else if (source != null) { + buffer.write('\nSource: ${source!.substring(0, 100)}...'); + } + if (details != null) { + buffer.write('\nDetails: $details'); + } + return buffer.toString(); + } +} + +/// Exception thrown when validation fails +class ValidationException extends AppException { + final String? fieldName; + final dynamic invalidValue; + final List? validationRules; + + ValidationException( + super.message, [ + super.details, + super.stackTrace, + this.fieldName, + this.invalidValue, + this.validationRules, + ]); + + @override + String toString() { + final buffer = StringBuffer('ValidationException: $message'); + if (fieldName != null) { + buffer.write('\nField: $fieldName'); + } + if (invalidValue != null) { + buffer.write('\nInvalid Value: $invalidValue'); + } + if (validationRules != null && validationRules!.isNotEmpty) { + buffer.write('\nValidation Rules: ${validationRules!.join(", ")}'); + } + if (details != null) { + buffer.write('\nDetails: $details'); + } + return buffer.toString(); + } +} diff --git a/lib/utils/retry_handler.dart b/lib/utils/retry_handler.dart new file mode 100644 index 0000000..3203dae --- /dev/null +++ b/lib/utils/retry_handler.dart @@ -0,0 +1,76 @@ +import 'dart:math'; + +/// 重试处理器,支持指数退避策略 +class RetryHandler { + static const int maxRetries = 3; + static const Duration initialDelay = Duration(seconds: 1); + static const double exponentialBase = 2.0; + + /// 执行带重试的异步操作 + /// + /// [operation] 要执行的异步操作 + /// [retryIf] 可选的条件函数,返回true时才重试 + /// [maxAttempts] 最大尝试次数,默认为3次 + /// [onRetry] 可选的重试回调,参数为当前尝试次数和错误 + static Future retry({ + required Future Function() operation, + bool Function(dynamic error)? retryIf, + int maxAttempts = maxRetries, + void Function(int attempt, dynamic error)? onRetry, + }) async { + int attempt = 0; + dynamic lastError; + + while (true) { + try { + attempt++; + return await operation(); + } catch (e) { + lastError = e; + + // 检查是否应该重试 + if (attempt >= maxAttempts || (retryIf != null && !retryIf(e))) { + rethrow; + } + + // 计算延迟时间(指数退避) + final delay = initialDelay * pow(exponentialBase, attempt - 1); + + // 调用重试回调 + if (onRetry != null) { + onRetry(attempt, e); + } + + // 等待后重试 + await Future.delayed(delay); + } + } + } + + /// 判断错误是否应该重试(网络相关错误) + static bool shouldRetryOnError(dynamic error) { + // 可以根据具体错误类型判断是否应该重试 + // 例如:网络超时、连接失败等应该重试 + // 认证失败、参数错误等不应该重试 + final errorStr = error.toString().toLowerCase(); + + // 应该重试的错误类型 + if (errorStr.contains('timeout') || + errorStr.contains('connection') || + errorStr.contains('network') || + errorStr.contains('socket')) { + return true; + } + + // 不应该重试的错误类型 + if (errorStr.contains('authentication') || + errorStr.contains('unauthorized') || + errorStr.contains('forbidden') || + errorStr.contains('invalid')) { + return false; + } + + // 默认重试 + return true; + } +} diff --git a/lib/utils/session_manager.dart b/lib/utils/session_manager.dart new file mode 100644 index 0000000..0ffccf3 --- /dev/null +++ b/lib/utils/session_manager.dart @@ -0,0 +1,263 @@ +import 'package:flutter/foundation.dart'; +import '../models/user_credentials.dart'; +import '../providers/auth_provider.dart'; + +/// Session manager for handling app startup and session restoration +/// +/// Provides utilities for: +/// - Checking if saved credentials exist +/// - Attempting to restore previous session +/// - Handling session expiration +/// +/// Usage example: +/// ```dart +/// final sessionManager = SessionManager(authProvider: authProvider); +/// +/// // Check if session can be restored +/// final canRestore = await sessionManager.canRestoreSession(); +/// +/// // Attempt to restore session +/// final restored = await sessionManager.restoreSession(); +/// +/// // Handle session expiration +/// await sessionManager.handleSessionExpired(); +/// ``` +class SessionManager { + final AuthProvider _authProvider; + + SessionManager({required AuthProvider authProvider}) + : _authProvider = authProvider; + + /// Check if saved credentials exist + /// + /// Returns true if credentials are stored, false otherwise + /// Does not validate if the session is still active + Future hasSavedCredentials() async { + try { + final credentials = await UserCredentials.loadSecurely(); + return credentials != null; + } catch (e) { + debugPrint('Error checking saved credentials: $e'); + return false; + } + } + + /// Check if session can be restored + /// + /// Checks if saved credentials exist and are valid + /// Returns true if session restoration should be attempted + Future canRestoreSession() async { + return await hasSavedCredentials(); + } + + /// Attempt to restore session from saved credentials + /// + /// Loads credentials from secure storage and attempts login + /// Returns SessionRestoreResult with status and details + Future restoreSession() async { + try { + // Check if credentials exist + final hasCredentials = await hasSavedCredentials(); + if (!hasCredentials) { + return SessionRestoreResult( + success: false, + reason: SessionRestoreFailureReason.noCredentials, + message: '未找到保存的登录凭证', + ); + } + + // Attempt to restore session using AuthProvider + final restored = await _authProvider.restoreSession(); + + if (restored) { + return SessionRestoreResult(success: true, message: '会话恢复成功'); + } else { + // Check the error message from auth provider + final errorMessage = _authProvider.errorMessage; + final reason = _determineFailureReason(errorMessage); + + return SessionRestoreResult( + success: false, + reason: reason, + message: errorMessage ?? '会话恢复失败', + ); + } + } catch (e) { + debugPrint('Error restoring session: $e'); + return SessionRestoreResult( + success: false, + reason: SessionRestoreFailureReason.unknown, + message: '会话恢复出错: $e', + ); + } + } + + /// Handle session expiration + /// + /// Clears current session and credentials + /// Should be called when session is detected as expired + Future handleSessionExpired() async { + try { + await _authProvider.logout(); + debugPrint('Session expired and cleared'); + } catch (e) { + debugPrint('Error handling session expiration: $e'); + } + } + + /// Clear saved session data + /// + /// Removes all saved credentials and session information + /// Useful for logout or when user wants to clear data + Future clearSession() async { + try { + await _authProvider.logout(); + debugPrint('Session cleared successfully'); + } catch (e) { + debugPrint('Error clearing session: $e'); + } + } + + /// Validate current session + /// + /// Checks if the current session is still valid + /// Returns true if session is active and healthy + Future validateSession() async { + try { + if (!_authProvider.isAuthenticated) { + return false; + } + + return await _authProvider.checkSession(); + } catch (e) { + debugPrint('Error validating session: $e'); + return false; + } + } + + /// Get session status + /// + /// Returns current session status information + SessionStatus getSessionStatus() { + return SessionStatus( + isAuthenticated: _authProvider.isAuthenticated, + authState: _authProvider.state, + hasConnection: _authProvider.connection != null, + errorMessage: _authProvider.errorMessage, + ); + } + + /// Determine failure reason from error message + SessionRestoreFailureReason _determineFailureReason(String? errorMessage) { + if (errorMessage == null) { + return SessionRestoreFailureReason.unknown; + } + + if (errorMessage.contains('密码错误') || errorMessage.contains('凭证')) { + return SessionRestoreFailureReason.invalidCredentials; + } else if (errorMessage.contains('网络') || errorMessage.contains('连接')) { + return SessionRestoreFailureReason.networkError; + } else if (errorMessage.contains('过期')) { + return SessionRestoreFailureReason.sessionExpired; + } else { + return SessionRestoreFailureReason.unknown; + } + } +} + +/// Result of session restoration attempt +class SessionRestoreResult { + final bool success; + final SessionRestoreFailureReason? reason; + final String message; + + SessionRestoreResult({ + required this.success, + this.reason, + required this.message, + }); + + @override + String toString() { + return 'SessionRestoreResult(success: $success, reason: $reason, message: $message)'; + } +} + +/// Reasons why session restoration might fail +enum SessionRestoreFailureReason { + /// No saved credentials found + noCredentials, + + /// Saved credentials are invalid + invalidCredentials, + + /// Session has expired + sessionExpired, + + /// Network connection error + networkError, + + /// Unknown error + unknown, +} + +/// Current session status information +class SessionStatus { + final bool isAuthenticated; + final AuthState authState; + final bool hasConnection; + final String? errorMessage; + + SessionStatus({ + required this.isAuthenticated, + required this.authState, + required this.hasConnection, + this.errorMessage, + }); + + /// Check if session is healthy + bool get isHealthy => + isAuthenticated && hasConnection && errorMessage == null; + + @override + String toString() { + return 'SessionStatus(' + 'isAuthenticated: $isAuthenticated, ' + 'authState: $authState, ' + 'hasConnection: $hasConnection, ' + 'errorMessage: $errorMessage' + ')'; + } +} + +/// Extension methods for SessionRestoreFailureReason +extension SessionRestoreFailureReasonExtension on SessionRestoreFailureReason { + /// Get user-friendly message for the failure reason + String get userMessage { + switch (this) { + case SessionRestoreFailureReason.noCredentials: + return '未找到保存的登录信息,请重新登录'; + case SessionRestoreFailureReason.invalidCredentials: + return '登录凭证无效,请重新登录'; + case SessionRestoreFailureReason.sessionExpired: + return '会话已过期,请重新登录'; + case SessionRestoreFailureReason.networkError: + return '网络连接失败,请检查网络后重试'; + case SessionRestoreFailureReason.unknown: + return '会话恢复失败,请重新登录'; + } + } + + /// Check if user should be prompted to login again + bool get shouldPromptLogin { + switch (this) { + case SessionRestoreFailureReason.noCredentials: + case SessionRestoreFailureReason.invalidCredentials: + case SessionRestoreFailureReason.sessionExpired: + return true; + case SessionRestoreFailureReason.networkError: + case SessionRestoreFailureReason.unknown: + return false; // User might want to retry + } + } +} diff --git a/lib/utils/text_generator.dart b/lib/utils/text_generator.dart new file mode 100644 index 0000000..39a10e3 --- /dev/null +++ b/lib/utils/text_generator.dart @@ -0,0 +1,194 @@ +import 'dart:math'; +import '../models/questionnaire.dart'; + +/// Text generator for evaluation questionnaires +/// Generates appropriate text responses based on question type +class TextGenerator { + static final Random _random = Random(); + + // 启发类文案库 + static const List inspirationTexts = [ + "老师认真负责的态度和丰富的讲课内容,让我明白了扎实的知识积累对学习的重要性", + "老师能够深入了解学生的学习状况,启发我学会了关注细节、因材施教的道理", + "老师授课有条理有重点,教会我做事要分清主次、抓住关键的思维方法", + "老师善于用凝练的语言表达复杂内容,让我学会了如何提炼要点、化繁为简", + "老师对深奥现象解释得通俗易懂,启发我认识到深入浅出是一种重要的能力", + "老师采用多种教学方式让学生更好接受知识,让我明白了方法灵活运用的重要性", + "老师既严格要求又鼓励学生发言,教会我严慈相济、宽严并济的处事原则", + "老师能够调动学生的积极性,启发我懂得了激发他人潜能和主动性的价值", + "老师课堂气氛活跃但不失严谨,让我理解了轻松与高效可以兼得的道理", + "老师治学严谨、循循善诱的风格,激励我要保持谦逊认真的学习态度和钻研精神", + "老师对学科的热爱和投入,让我感受到保持热情对做好任何事情的重要意义", + "老师善于联系实际讲解理论知识,启发我学会了理论联系实际的思维方式", + "老师注重培养学生的自主学习能力,让我明白了授人以渔的教育真谛", + "老师对每个问题的耐心解答,教会我做事要有耐心和责任心", + "老师在课堂上的幽默感,让我懂得了适度的轻松能够提高工作和学习效率", + "老师严格的课堂管理,启发我认识到纪律和规则对集体活动的重要性", + "老师丰富的专业知识储备,激励我要不断充实自己、拓宽知识面", + "老师对学生的一视同仁,让我理解了公平公正待人的重要价值", + "老师善于鼓励和肯定学生,教会我正面激励对他人成长的积极作用", + "老师清晰的逻辑思维,启发我学会了有条理地思考和表达问题", + "老师对教学的精心准备,让我明白了充分准备是做好工作的前提", + "老师善于归纳总结重点,教会我抓住事物本质和核心的思维能力", + "老师对学生问题的重视,启发我懂得了倾听和尊重他人意见的重要性", + "老师灵活的教学节奏把握,让我学会了根据实际情况灵活调整的智慧", + "老师富有感染力的授课方式,教会我热情和真诚能够打动他人", + "老师注重学生的全面发展,启发我认识到综合素质培养的重要性", + "老师对细节的关注,让我明白了细节决定成败的道理", + "老师善于启发学生独立思考,教会我批判性思维和质疑精神的可贵", + "老师持续学习、与时俱进的态度,激励我要保持终身学习的理念", + "老师对学生的关心和帮助,让我理解了教书育人、为人师表的深刻内涵", + ]; + + // 建议类文案库 + static const List suggestionTexts = [ + '无', + '没有', + "老师讲课很好,很认真负责,我没有什么建议,希望老师继续保持现有的教学方式", + "老师授课认真,课堂效率高,我觉得一切都很好,暂时没有什么意见和建议", + "老师上课既幽默又严格,教学方法很适合我们,没有需要改进的地方", + "老师治学严谨,循循善诱,对老师的授课我非常满意,请老师保持这种教学状态", + "老师授课有条理有重点,我认为已经做得很到位了,没有什么建议可提", + "老师善于用凝练的语言讲解复杂内容,教学方式很好,希望老师继续发扬优点", + "老师讲课内容详细,条理清晰,我觉得没有什么需要调整的地方,一切都很棒", + "老师讲授认真,内容丰富,我对教学方式非常认可,请老师保持现在的风格", + "老师对待教学认真负责,能够调动学生积极性,我没有什么意见,希望老师继续保持", + "老师课堂效率高,气氛活跃,整节课学下来很有收获,暂时想不到需要改进的地方", + "老师教学态度端正,讲课思路清晰,我觉得非常好,没有什么意见和建议", + "老师授课生动有趣,深入浅出,对老师的教学我很满意,请老师保持下去", + "老师对学生要求严格但不失关怀,教学方法得当,我没有什么建议可提", + "老师讲课重点突出,内容充实,我认为一切都很好,希望老师继续保持", + "老师课堂互动性强,能照顾到每个学生,我觉得没有需要改进的地方", + "老师备课充分,讲解透彻,对老师的授课非常认可,暂时没有什么意见", + "老师教学经验丰富,方法多样,我觉得已经很优秀了,请老师保持现状", + "老师语言表达清晰,逻辑性强,我没有什么建议,希望老师继续发扬", + "老师授课节奏把握得很好,我认为非常合适,没有什么需要调整的", + "老师对待学生耐心负责,教学效果显著,我很满意,请老师保持", + "老师讲课富有激情,能感染学生,我觉得很好,暂时没有什么意见", + "老师专业知识扎实,讲解到位,对老师的教学我非常认可,没有建议", + "老师善于引导学生思考,启发性强,我认为一切都很好,请老师保持", + "老师课堂管理有序,教学效率高,我觉得没有什么需要改进的地方", + "老师授课风格独特,深受学生喜爱,我没有什么意见和建议", + "老师讲课深入浅出,通俗易懂,我认为非常好,希望老师继续保持", + "老师对学生一视同仁,公平公正,我很满意老师的教学方式", + "老师教学方法科学合理,效果突出,我觉得没有需要调整的地方", + "老师认真批改作业,及时反馈,对老师的工作我非常认可,请保持", + "老师课堂内容丰富多彩,讲解细致入微,我没有什么建议,一切都很好", + ]; + + // 总体评价文案库 + static const List overallTexts = [ + "老师讲课认真负责,课程内容充实丰富,理论与实践结合得很好,让我收获颇丰,对专业知识有了更深入的理解", + "老师授课条理清晰,课程设置合理,由浅入深,循序渐进,学习过程中既有挑战性又能跟上节奏", + "老师教学方法灵活多样,课程内容非常实用,学到的知识能够应用到实际中,让我感受到了学以致用的乐趣", + "老师讲课生动有趣,课程内容丰富多彩,涵盖面广,开阔了我的视野,激发了我对这个领域更浓厚的兴趣", + "老师治学严谨,循循善诱,通过这门课程让我建立了完整的知识体系,培养了逻辑思维能力和分析问题的能力", + "老师授课重点突出,课程难度适中,既巩固了基础知识,又拓展了深度内容,满足了我的学习需求", + "老师善于启发学生思考,课程注重培养实践能力和创新思维,让我不仅学到了知识,更学会了如何解决问题", + "老师讲解详细透彻,课程安排紧凑合理,通过学习让我对该学科有了系统而全面的认识", + "老师课堂气氛活跃,能调动学生积极性,这门课程很有启发性,培养了我的自主学习能力和探索精神", + "老师教学认真,内容讲授清晰明确,课程与时俱进,紧跟学科发展,整体学习体验非常好,让我受益匪浅", + "老师备课充分,课程内容环环相扣,逻辑严密,让我掌握了扎实的专业基础知识", + "老师授课富有激情,课程设计新颖独特,学习过程充满乐趣,让我对学习保持了浓厚兴趣", + "老师对学生认真负责,课程作业设置合理,既能巩固知识又不会过于繁重,学习效果很好", + "老师讲课深入浅出,课程内容由易到难,知识点讲解透彻,让我能够循序渐进地掌握知识", + "老师善于互动交流,课程注重学生参与,让我在积极的课堂氛围中提高了学习效率", + "老师专业素养高,课程内容前沿实用,让我了解到了学科的最新发展动态和应用前景", + "老师授课方式灵活,课程形式多样,既有理论讲解又有案例分析,让学习更加立体生动", + "老师对学生耐心指导,课程考核方式合理,既注重过程又关注结果,让我全面提升了能力", + "老师治学态度严谨,课程内容系统完整,帮助我构建了完善的知识框架和学科思维", + "老师善于举例说明,课程理论联系实际,让抽象的概念变得具体易懂,提高了我的理解能力", + "老师课堂管理有序,课程进度把握得当,既保证了教学质量又照顾到了学生的接受能力", + "老师讲课条理分明,课程重难点突出,让我能够抓住学习的关键,提高了学习效率", + "老师对学生要求严格,课程训练扎实有效,让我养成了良好的学习习惯和严谨的学习态度", + "老师授课语言生动,课程内容引人入胜,每节课都能让我保持高度的专注和学习热情", + "老师善于总结归纳,课程知识点梳理清晰,帮助我建立了清晰的知识脉络和记忆框架", + "老师注重能力培养,课程不仅传授知识更注重方法,让我掌握了学习和研究的基本方法", + "老师课堂效果显著,课程学习收获很大,不仅提升了专业水平也拓宽了思维视野", + "老师教学经验丰富,课程设计科学合理,让我在轻松愉快的氛围中完成了学习任务", + "老师对学生关怀备至,课程教学以学生为中心,充分考虑了我们的实际需求和接受能力", + "老师讲课精彩纷呈,课程内容充实饱满,每次上课都有新的收获和感悟,让我的学习充满期待", + ]; + + /// Generate text based on question type + /// Returns a random text from the appropriate text library + String generate(QuestionType type) { + String text; + + switch (type) { + case QuestionType.inspiration: + text = inspirationTexts[_random.nextInt(inspirationTexts.length)]; + break; + case QuestionType.suggestion: + text = suggestionTexts[_random.nextInt(suggestionTexts.length)]; + break; + case QuestionType.overall: + text = overallTexts[_random.nextInt(overallTexts.length)]; + break; + case QuestionType.general: + // For general type, use overall texts as fallback + text = overallTexts[_random.nextInt(overallTexts.length)]; + break; + } + + // Apply text processing rules + text = _removeSpaces(text); + text = _ensureMinLength(text); + + return text; + } + + /// Ensure text has at least 4 characters + /// If text is shorter, pad with appropriate content + String _ensureMinLength(String text) { + if (text.length >= 4) { + return text; + } + + // For very short texts like "无" or "没有", they are already valid + // Just return as is since Chinese characters count as valid + return text; + } + + /// Remove all spaces from text + String _removeSpaces(String text) { + return text.replaceAll(' ', ''); + } + + /// Validate if text meets all requirements + /// Returns true if text is valid, false otherwise + bool validate(String text) { + // Check minimum length (at least 4 characters) + if (text.length < 4) { + return false; + } + + // Check for spaces (should not contain any) + if (text.contains(' ')) { + return false; + } + + // Check for 3 or more consecutive identical characters + if (_hasConsecutiveChars(text)) { + return false; + } + + return true; + } + + /// Check if text has 3 or more consecutive identical characters + /// Returns true if found, false otherwise + bool _hasConsecutiveChars(String text) { + if (text.length < 3) { + return false; + } + + for (int i = 0; i < text.length - 2; i++) { + if (text[i] == text[i + 1] && text[i] == text[i + 2]) { + return true; + } + } + + return false; + } +} diff --git a/lib/widgets/.gitkeep b/lib/widgets/.gitkeep new file mode 100644 index 0000000..a8f3559 --- /dev/null +++ b/lib/widgets/.gitkeep @@ -0,0 +1,2 @@ +# Widgets directory +# This directory contains reusable widgets diff --git a/lib/widgets/confirm_dialog.dart b/lib/widgets/confirm_dialog.dart new file mode 100644 index 0000000..2070380 --- /dev/null +++ b/lib/widgets/confirm_dialog.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +/// Confirmation dialog widget +/// +/// Displays a confirmation dialog with customizable title, content, and buttons +class ConfirmDialog extends StatelessWidget { + final String title; + final String content; + final String confirmText; + final String cancelText; + final bool isDangerous; + + const ConfirmDialog({ + super.key, + required this.title, + required this.content, + this.confirmText = '确认', + this.cancelText = '取消', + this.isDangerous = false, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(cancelText), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: isDangerous + ? TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ) + : null, + child: Text(confirmText), + ), + ], + ); + } + + /// Show confirmation dialog + static Future show( + BuildContext context, { + required String title, + required String content, + String confirmText = '确认', + String cancelText = '取消', + bool isDangerous = false, + }) async { + final result = await showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: title, + content: content, + confirmText: confirmText, + cancelText: cancelText, + isDangerous: isDangerous, + ), + ); + return result ?? false; + } +} diff --git a/lib/widgets/course_card.dart b/lib/widgets/course_card.dart new file mode 100644 index 0000000..f04b527 --- /dev/null +++ b/lib/widgets/course_card.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import '../models/course.dart'; + +/// Course card widget +/// +/// Displays course information in a card format +/// Shows course name, teacher, and evaluation status +class CourseCard extends StatelessWidget { + final Course course; + + const CourseCard({super.key, required this.course}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Course name and status + Row( + children: [ + Expanded( + child: Text( + course.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + _buildStatusChip(context), + ], + ), + const SizedBox(height: 8), + + // Teacher info + Row( + children: [ + Icon( + Icons.person, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + course.teacher, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 4), + + // Evaluated people info + Row( + children: [ + Icon( + Icons.group, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + course.evaluatedPeople, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatusChip(BuildContext context) { + if (course.isEvaluated) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle, size: 14, color: Colors.green), + const SizedBox(width: 4), + Text( + '已评', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } else { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.pending, + size: 14, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 4), + Text( + '待评', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + } +} diff --git a/lib/widgets/error_dialog.dart b/lib/widgets/error_dialog.dart new file mode 100644 index 0000000..1623f75 --- /dev/null +++ b/lib/widgets/error_dialog.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +/// Error dialog widget +/// +/// Displays an error message with an icon and optional action button +class ErrorDialog extends StatelessWidget { + final String title; + final String message; + final String? actionText; + final VoidCallback? onAction; + + const ErrorDialog({ + super.key, + required this.title, + required this.message, + this.actionText, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: Icon( + Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.error, + ), + title: Text(title), + content: Text(message), + actions: [ + if (actionText != null && onAction != null) + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onAction!(); + }, + child: Text(actionText!), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('关闭'), + ), + ], + ); + } + + /// Show error dialog + static Future show( + BuildContext context, { + required String title, + required String message, + String? actionText, + VoidCallback? onAction, + }) { + return showDialog( + context: context, + builder: (context) => ErrorDialog( + title: title, + message: message, + actionText: actionText, + onAction: onAction, + ), + ); + } +} diff --git a/lib/widgets/loading_indicator.dart b/lib/widgets/loading_indicator.dart new file mode 100644 index 0000000..2529567 --- /dev/null +++ b/lib/widgets/loading_indicator.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +/// Loading indicator widget +/// +/// Displays a circular progress indicator with optional message +class LoadingIndicator extends StatelessWidget { + final String? message; + final double size; + + const LoadingIndicator({super.key, this.message, this.size = 40}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: size, + height: size, + child: CircularProgressIndicator(strokeWidth: size / 10), + ), + if (message != null) ...[ + const SizedBox(height: 16), + Text( + message!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + ); + } +} diff --git a/logo.ico b/logo.ico new file mode 100644 index 0000000..700cd2e Binary files /dev/null and b/logo.ico differ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..efbc5e0 Binary files /dev/null and b/logo.png differ diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..351f350 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,602 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "55b9b229307a10974b26296ff29f2e132256ba4bd74266939118eaefa941cb00" + url: "https://pub.dev" + source: hosted + version: "16.3.3" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + html: + dependency: "direct main" + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 + url: "https://pub.dev" + source: hosted + version: "2.2.20" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: "direct main" + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" +sdks: + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..2f6e989 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,118 @@ +name: loveace_autojudge +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.9.2 + +# 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 + + # State management + provider: ^6.1.1 + + # Network + dio: ^5.4.0 + + # HTML parsing + html: ^0.15.4 + + # Secure storage + flutter_secure_storage: ^9.0.0 + + # Local notifications + flutter_local_notifications: ^16.3.0 + + # Local storage + shared_preferences: ^2.2.2 + + # Encryption + crypto: ^3.0.3 + pointycastle: ^3.7.3 + + # Logging + logger: ^2.0.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: ^5.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: + - assets/logo.png + + # 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 + + fonts: + - family: MiSans + fonts: + - asset: fonts/MiSans-Regular.otf diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a 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..b749bfe 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..88cfd48 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..eb9b4d7 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..d69c566 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..4572cd7 --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + loveace_autojudge + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..86eed7e --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "loveace_autojudge", + "short_name": "loveace_autojudge", + "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..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..a3b2b98 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(loveace_autojudge 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 "loveace_autojudge") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..0c50753 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..4fc759c --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..132e588 --- /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", "meow.loveace.autojudge" "\0" + VALUE "FileDescription", "LoveAce AutoJudge" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "meow.loveace.autojudge.loveace_autojudge" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 meow.loveace.autojudge. All rights reserved." "\0" + VALUE "OriginalFilename", "loveace_autojudge.exe" "\0" + VALUE "ProductName", "LoveAce AutoJudge" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..d96a966 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,49 @@ +#include +#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); + + // Set Application User Model ID for proper Windows integration + // This ensures data is stored in the correct location + ::SetCurrentProcessExplicitAppUserModelID( + L"meow.loveace.autojudge.loveace_autojudge"); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"loveace_autojudge", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..700cd2e Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_