😋 初始化仓库

This commit is contained in:
2025-11-13 09:14:49 +08:00
commit 347d264437
133 changed files with 11214 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

39
.metadata Normal file
View File

@@ -0,0 +1,39 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "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'

16
README.md Normal file
View File

@@ -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.

28
analysis_options.yaml Normal file
View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "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 = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,54 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Internet permission for network requests -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Notification permissions for Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Vibration permission for notifications -->
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:label="loveace_autojudge"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package meow.loveace.autojudge.loveace_autojudge
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

BIN
fonts/MiSans-Regular.otf Normal file

Binary file not shown.

34
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -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.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

58
ios/Runner/Info.plist Normal file
View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Loveace Autojudge</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>loveace_autojudge</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- Network usage description -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- Notification permission description -->
<key>NSUserNotificationsUsageDescription</key>
<string>需要通知权限以便在评教过程中向您发送进度更新</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

59
iss.iss Normal file
View File

@@ -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

137
lib/main.dart Normal file
View File

@@ -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<AuthProvider, EvaluationProvider>(
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<ThemeProvider>(
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<AppInitializer> createState() => _AppInitializerState();
}
class _AppInitializerState extends State<AppInitializer> {
bool _isInitializing = true;
bool _hasSession = false;
@override
void initState() {
super.initState();
// Defer initialization until after the first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
_initialize();
});
}
Future<void> _initialize() async {
final authProvider = Provider.of<AuthProvider>(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();
}
}

2
lib/models/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# Models directory
# This directory contains data models for the application

View File

@@ -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,
);
}
}

101
lib/models/course.dart Normal file
View File

@@ -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<String, dynamic> 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<String, dynamic> 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;
}

View File

@@ -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<String, dynamic> json) {
return EvaluationHistory(
id: json['id'] as String? ?? '',
course: Course.fromJson(json['course'] as Map<String, dynamic>),
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'] as String)
: DateTime.now(),
success: json['success'] as bool? ?? false,
errorMessage: json['errorMessage'] as String?,
);
}
Map<String, dynamic> 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;
}

View File

@@ -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<String, dynamic> json) {
return EvaluationResult(
course: Course.fromJson(json['course'] as Map<String, dynamic>),
success: json['success'] as bool? ?? false,
errorMessage: json['errorMessage'] as String?,
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'] as String)
: DateTime.now(),
);
}
Map<String, dynamic> 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<EvaluationResult> results;
final Duration duration;
BatchEvaluationResult({
required this.total,
required this.success,
required this.failed,
required this.results,
required this.duration,
});
factory BatchEvaluationResult.fromJson(Map<String, dynamic> 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<dynamic>?)
?.map((e) => EvaluationResult.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
duration: Duration(milliseconds: json['durationMs'] as int? ?? 0),
);
}
Map<String, dynamic> 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)';
}
}

View File

@@ -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;
}

6
lib/models/models.dart Normal file
View File

@@ -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';

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<RadioOption> options;
final String category; // 如"师德师风"、"教学内容"
RadioQuestion({
required this.key,
required this.questionText,
required this.options,
this.category = '',
});
factory RadioQuestion.fromJson(Map<String, dynamic> json) {
return RadioQuestion(
key: json['key'] as String? ?? '',
questionText: json['questionText'] as String? ?? '',
options:
(json['options'] as List<dynamic>?)
?.map((e) => RadioOption.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
category: json['category'] as String? ?? '',
);
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<RadioQuestion> radioQuestions;
final List<TextQuestion> 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<String, dynamic> json) {
return Questionnaire(
metadata: QuestionnaireMetadata.fromJson(
json['metadata'] as Map<String, dynamic>? ?? {},
),
radioQuestions:
(json['radioQuestions'] as List<dynamic>?)
?.map((e) => RadioQuestion.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
textQuestions:
(json['textQuestions'] as List<dynamic>?)
?.map((e) => TextQuestion.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
tokenValue: json['tokenValue'] as String? ?? '',
questionnaireCode: json['questionnaireCode'] as String? ?? '',
evaluationContent: json['evaluationContent'] as String? ?? '',
evaluatedPeopleNumber: json['evaluatedPeopleNumber'] as String? ?? '',
);
}
Map<String, dynamic> 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})';
}
}

View File

@@ -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<String, dynamic> json) {
return UserCredentials(
userId: json['userId'] as String? ?? '',
ecPassword: json['ecPassword'] as String? ?? '',
password: json['password'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {'userId': userId, 'ecPassword': ecPassword, 'password': password};
}
/// Save credentials securely using flutter_secure_storage
Future<void> 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<UserCredentials?> 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<void> 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);
}

2
lib/providers/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# Providers directory
# This directory contains state management providers

View File

@@ -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<AuthProvider>(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<bool> 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<void> 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<bool> 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<bool> 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();
}
}

View File

@@ -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<EvaluationProvider>(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<Course> _courses = [];
EvaluationState _state = EvaluationState.idle;
BatchEvaluationResult? _lastResult;
String? _errorMessage;
List<EvaluationHistory> _evaluationHistory = [];
// Progress tracking
int _currentProgress = 0;
int _totalProgress = 0;
Course? _currentCourse;
String? _currentStatus;
final List<String> _logs = [];
// Countdown tracking
int _countdownRemaining = 0;
int _countdownTotal = 0;
// Concurrent evaluation tracking
final List<ConcurrentTask> _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<Course> 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<String> 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<ConcurrentTask> 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<Course> get pendingCourses =>
_courses.where((c) => !c.isEvaluated).toList();
/// Get evaluated courses
List<Course> get evaluatedCourses =>
_courses.where((c) => c.isEvaluated).toList();
/// Get failed courses from last batch evaluation
List<Course> get failedCourses {
if (_lastResult == null) return [];
return _lastResult!.results
.where((r) => !r.success)
.map((r) => r.course)
.toList();
}
/// Get evaluation history
List<EvaluationHistory> get evaluationHistory => _evaluationHistory;
/// Load evaluation history from storage
Future<void> _loadEvaluationHistory() async {
try {
_evaluationHistory = await _storageService.loadEvaluationHistory();
notifyListeners();
} catch (e) {
debugPrint('Failed to load evaluation history: $e');
}
}
/// Refresh evaluation history from storage
Future<void> refreshEvaluationHistory() async {
await _loadEvaluationHistory();
}
/// Clear evaluation history
Future<void> clearEvaluationHistory() async {
try {
await _storageService.clearEvaluationHistory();
_evaluationHistory = [];
notifyListeners();
} catch (e) {
debugPrint('Failed to clear evaluation history: $e');
}
}
/// Save evaluation results to history
Future<void> _saveEvaluationResults(List<EvaluationResult> 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<bool> 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<BatchEvaluationResult?> 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 = <EvaluationResult>[];
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<BatchEvaluationResult?> 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 = <EvaluationResult>[];
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<EvaluationResult?> 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<BatchEvaluationResult?> 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 = <Future<EvaluationResult>>[];
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<EvaluationResult> _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();
}
}
}

View File

@@ -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<ThemeProvider>(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<void> 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<void> setColorScheme(AppColorScheme scheme) async {
if (_colorScheme == scheme) return;
_colorScheme = scheme;
notifyListeners();
await savePreferences();
}
/// Save preferences to local storage
Future<void> 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<void> _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 '跟随系统';
}
}
}

2
lib/screens/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# Screens directory
# This directory contains UI screens

View File

@@ -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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
// Defer loading until after the first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadCourses();
});
}
Future<void> _loadCourses() async {
if (!mounted) return;
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final evaluationProvider = Provider.of<EvaluationProvider>(
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<void> _handleRefresh() async {
await _loadCourses();
}
Future<void> _handleBatchEvaluation() async {
final evaluationProvider = Provider.of<EvaluationProvider>(
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<EvaluationProvider>(
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<EvaluationProvider>(
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,
),
),
],
);
}
}

View File

@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) {
return;
}
final authProvider = Provider.of<AuthProvider>(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<AuthProvider>(
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,
),
],
),
],
),
),
),
),
),
);
}
}

View File

@@ -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<bool> _onWillPop(BuildContext context, EvaluationState state) async {
// 如果已完成或出错,允许直接返回
if (state == EvaluationState.completed || state == EvaluationState.error) {
return true;
}
// 如果正在评教,显示确认对话框
final shouldPop = await showDialog<bool>(
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<EvaluationProvider>(
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<Color>(
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<EvaluationProvider>(
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<Color>(
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<EvaluationProvider>(
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),
],
),
);
}
}

View File

@@ -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<void> _handleLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => const ConfirmDialog(
title: '退出登录',
content: '确定要退出登录吗?',
confirmText: '退出',
cancelText: '取消',
),
);
if (confirmed != true || !context.mounted) return;
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final evaluationProvider = Provider.of<EvaluationProvider>(
context,
listen: false,
);
await authProvider.logout();
evaluationProvider.reset();
if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginScreen()),
(route) => false,
);
}
Future<void> _handleClearData(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => const ConfirmDialog(
title: '清除数据',
content: '确定要清除所有本地数据吗?\n这将删除评教历史记录。',
confirmText: '清除',
cancelText: '取消',
),
);
if (confirmed != true || !context.mounted) return;
final evaluationProvider = Provider.of<EvaluationProvider>(
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<ThemeProvider>(
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<ThemeProvider>(
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<AuthProvider>(
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<ThemeMode>(
title: const Text('浅色模式'),
value: ThemeMode.light,
groupValue: currentMode,
onChanged: (mode) {
if (mode != null) onModeSelected(mode);
},
),
RadioListTile<ThemeMode>(
title: const Text('深色模式'),
value: ThemeMode.dark,
groupValue: currentMode,
onChanged: (mode) {
if (mode != null) onModeSelected(mode);
},
),
RadioListTile<ThemeMode>(
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<AppColorScheme>(
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);
},
);
}
}

2
lib/services/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# Services directory
# This directory contains business logic services

View File

@@ -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<InitializationResult> initialize() async {
final startTime = DateTime.now();
final steps = <InitializationStep>[];
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<InitializationStep> _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<InitializationStep> _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<InitializationStep> _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<InitializationStep> _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<InitializationStep> 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<InitializationStep> get failedSteps =>
steps.where((step) => !step.success).toList();
/// Get successful steps
List<InitializationStep> 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'
')';
}
}

View File

@@ -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<ECLoginStatus> ecLogin() async {
try {
return await RetryHandler.retry(
operation: () async => await _performEcLogin(),
retryIf: RetryHandler.shouldRetryOnError,
maxAttempts: 3,
);
} catch (e) {
return ECLoginStatus(failUnknownError: true);
}
}
Future<ECLoginStatus> _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'<TwfID>(.*?)</TwfID>',
).firstMatch(responseText);
if (twfIdMatch == null) {
return ECLoginStatus(failNotFoundTwfid: true);
}
_twfId = twfIdMatch.group(1);
// 3. 提取RSA密钥
final rsaKeyMatch = RegExp(
r'<RSA_ENCRYPT_KEY>(.*?)</RSA_ENCRYPT_KEY>',
).firstMatch(responseText);
if (rsaKeyMatch == null) {
return ECLoginStatus(failNotFoundRsaKey: true);
}
final rsaKey = rsaKeyMatch.group(1)!;
// 4. 提取RSA指数
final rsaExpMatch = RegExp(
r'<RSA_ENCRYPT_EXP>(.*?)</RSA_ENCRYPT_EXP>',
).firstMatch(responseText);
if (rsaExpMatch == null) {
return ECLoginStatus(failNotFoundRsaExp: true);
}
final rsaExp = rsaExpMatch.group(1)!;
// 5. 提取CSRF代码
final csrfMatch = RegExp(
r'<CSRF_RAND_CODE>(.*?)</CSRF_RAND_CODE>',
).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('<Result>1</Result>')) {
_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<RSAPublicKey>(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<UAAPLoginStatus> uaapLogin() async {
try {
return await RetryHandler.retry(
operation: () async => await _performUaapLogin(),
retryIf: RetryHandler.shouldRetryOnError,
maxAttempts: 3,
);
} catch (e) {
return UAAPLoginStatus(failUnknownError: true);
}
}
Future<UAAPLoginStatus> _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<ECCheckStatus> 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<ECCheckStatus> checkUaapLoginStatus() async {
return ECCheckStatus(loggedIn: _uaapLogged);
}
/// 健康检查
Future<bool> 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<void> close() async {
_client.close();
}
/// 获取HTTP客户端
HTTPClient get client {
healthCheckpoint();
return _client;
}
/// 获取CSRF Token
Future<String?> 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<List<Course>> 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<String, dynamic> && data['data'] is List) {
final courseList = (data['data'] as List)
.map((item) => _parseCourse(item))
.where((course) => course != null)
.cast<Course>()
.toList();
return courseList;
}
return [];
} catch (e) {
return [];
}
}
/// 解析课程数据
Course? _parseCourse(dynamic item) {
try {
if (item is! Map<String, dynamic>) return null;
final id = item['id'] as Map<String, dynamic>?;
final questionnaire = item['questionnaire'] as Map<String, dynamic>?;
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<String?> 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<EvaluationResponse> submitEvaluation(
Map<String, String> 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<String, dynamic>) {
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';
}

View File

@@ -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<Map<String, String>?> 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<EvaluationResult> submitEvaluation(
Course course,
Map<String, String> 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<EvaluationResult> 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<String, String> _buildFormData(
Questionnaire questionnaire, {
required Course course,
int? totalCourses,
}) {
final formData = <String, String>{};
// 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<RadioOption> options) {
if (options.isEmpty) {
throw ArgumentError('Options list cannot be empty');
}
// Sort options by weight in descending order
final sortedOptions = List<RadioOption>.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<BatchEvaluationResult> batchEvaluate({
required Function(int current, int total, Course course, String status)
onProgress,
}) async {
final startTime = DateTime.now();
final results = <EvaluationResult>[];
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,
);
}
}
}

View File

@@ -0,0 +1,143 @@
import 'package:dio/dio.dart';
/// HTTP客户端封装类提供统一的网络请求接口
class HTTPClient {
late Dio _dio;
final Map<String, String> _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<Response> get(
String path, {
Map<String, dynamic>? params,
Options? options,
}) async {
return await _dio.get(path, queryParameters: params, options: options);
}
/// POST请求
Future<Response> post(
String path, {
dynamic data,
Map<String, dynamic>? 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<String, String> getAllCookies() {
return Map.from(_cookies);
}
/// 清除所有Cookie
void clearCookies() {
_cookies.clear();
}
/// 关闭客户端
void close() {
_dio.close();
}
/// 获取Dio实例用于高级操作
Dio get dio => _dio;
}

View File

@@ -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<bool> 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<void> _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<void> 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<void> 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<void> showCompletionNotification({
required int success,
required int failed,
required int total,
}) async {
// Notification service disabled
return;
}
/// Show error notification
///
/// [message] - Error message to display
Future<void> showErrorNotification(String message) async {
// Notification service disabled
return;
}
/// Cancel all active notifications
///
/// Clears all notifications from the notification tray
Future<void> cancelAll() async {
if (!_isInitialized) {
debugPrint('Notification service not initialized');
return;
}
try {
await _notifications.cancelAll();
} catch (e) {
debugPrint('Failed to cancel notifications: $e');
}
}
}

View File

@@ -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<String, Questionnaire> _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<Questionnaire> 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<RadioQuestion> _extractRadioQuestions(Document document) {
final Map<String, RadioQuestion> 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<TextQuestion> _extractTextQuestions(Document document) {
final List<TextQuestion> 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;
}
}

View File

@@ -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<void> 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<void> saveEvaluationHistories(
List<EvaluationHistory> 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<List<EvaluationHistory>> 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<dynamic>;
final histories = jsonList
.map(
(json) => EvaluationHistory.fromJson(json as Map<String, dynamic>),
)
.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<List<EvaluationHistory>> 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<int> 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<int> 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<void> 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<void> 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<DateTime?> 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<int> 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<void> 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<bool> 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<void> 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<StorageStats> 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';
}

2
lib/utils/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# Utils directory
# This directory contains utility functions and helpers

239
lib/utils/app_logger.dart Normal file
View File

@@ -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<String, dynamic>? 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<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
final sanitized = Map<String, dynamic>.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<String, dynamic>.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;
}
}
}

View File

@@ -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<void> runAppWithErrorHandling(
Future<void> 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();
},
),
),
);
}
}

148
lib/utils/exceptions.dart Normal file
View File

@@ -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<String>? 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();
}
}

View File

@@ -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<T> retry<T>({
required Future<T> 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;
}
}

View File

@@ -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<bool> 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<bool> 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<SessionRestoreResult> 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<void> 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<void> 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<bool> 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
}
}
}

View File

@@ -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<String> inspirationTexts = [
"老师认真负责的态度和丰富的讲课内容,让我明白了扎实的知识积累对学习的重要性",
"老师能够深入了解学生的学习状况,启发我学会了关注细节、因材施教的道理",
"老师授课有条理有重点,教会我做事要分清主次、抓住关键的思维方法",
"老师善于用凝练的语言表达复杂内容,让我学会了如何提炼要点、化繁为简",
"老师对深奥现象解释得通俗易懂,启发我认识到深入浅出是一种重要的能力",
"老师采用多种教学方式让学生更好接受知识,让我明白了方法灵活运用的重要性",
"老师既严格要求又鼓励学生发言,教会我严慈相济、宽严并济的处事原则",
"老师能够调动学生的积极性,启发我懂得了激发他人潜能和主动性的价值",
"老师课堂气氛活跃但不失严谨,让我理解了轻松与高效可以兼得的道理",
"老师治学严谨、循循善诱的风格,激励我要保持谦逊认真的学习态度和钻研精神",
"老师对学科的热爱和投入,让我感受到保持热情对做好任何事情的重要意义",
"老师善于联系实际讲解理论知识,启发我学会了理论联系实际的思维方式",
"老师注重培养学生的自主学习能力,让我明白了授人以渔的教育真谛",
"老师对每个问题的耐心解答,教会我做事要有耐心和责任心",
"老师在课堂上的幽默感,让我懂得了适度的轻松能够提高工作和学习效率",
"老师严格的课堂管理,启发我认识到纪律和规则对集体活动的重要性",
"老师丰富的专业知识储备,激励我要不断充实自己、拓宽知识面",
"老师对学生的一视同仁,让我理解了公平公正待人的重要价值",
"老师善于鼓励和肯定学生,教会我正面激励对他人成长的积极作用",
"老师清晰的逻辑思维,启发我学会了有条理地思考和表达问题",
"老师对教学的精心准备,让我明白了充分准备是做好工作的前提",
"老师善于归纳总结重点,教会我抓住事物本质和核心的思维能力",
"老师对学生问题的重视,启发我懂得了倾听和尊重他人意见的重要性",
"老师灵活的教学节奏把握,让我学会了根据实际情况灵活调整的智慧",
"老师富有感染力的授课方式,教会我热情和真诚能够打动他人",
"老师注重学生的全面发展,启发我认识到综合素质培养的重要性",
"老师对细节的关注,让我明白了细节决定成败的道理",
"老师善于启发学生独立思考,教会我批判性思维和质疑精神的可贵",
"老师持续学习、与时俱进的态度,激励我要保持终身学习的理念",
"老师对学生的关心和帮助,让我理解了教书育人、为人师表的深刻内涵",
];
// 建议类文案库
static const List<String> suggestionTexts = [
'',
'没有',
"老师讲课很好,很认真负责,我没有什么建议,希望老师继续保持现有的教学方式",
"老师授课认真,课堂效率高,我觉得一切都很好,暂时没有什么意见和建议",
"老师上课既幽默又严格,教学方法很适合我们,没有需要改进的地方",
"老师治学严谨,循循善诱,对老师的授课我非常满意,请老师保持这种教学状态",
"老师授课有条理有重点,我认为已经做得很到位了,没有什么建议可提",
"老师善于用凝练的语言讲解复杂内容,教学方式很好,希望老师继续发扬优点",
"老师讲课内容详细,条理清晰,我觉得没有什么需要调整的地方,一切都很棒",
"老师讲授认真,内容丰富,我对教学方式非常认可,请老师保持现在的风格",
"老师对待教学认真负责,能够调动学生积极性,我没有什么意见,希望老师继续保持",
"老师课堂效率高,气氛活跃,整节课学下来很有收获,暂时想不到需要改进的地方",
"老师教学态度端正,讲课思路清晰,我觉得非常好,没有什么意见和建议",
"老师授课生动有趣,深入浅出,对老师的教学我很满意,请老师保持下去",
"老师对学生要求严格但不失关怀,教学方法得当,我没有什么建议可提",
"老师讲课重点突出,内容充实,我认为一切都很好,希望老师继续保持",
"老师课堂互动性强,能照顾到每个学生,我觉得没有需要改进的地方",
"老师备课充分,讲解透彻,对老师的授课非常认可,暂时没有什么意见",
"老师教学经验丰富,方法多样,我觉得已经很优秀了,请老师保持现状",
"老师语言表达清晰,逻辑性强,我没有什么建议,希望老师继续发扬",
"老师授课节奏把握得很好,我认为非常合适,没有什么需要调整的",
"老师对待学生耐心负责,教学效果显著,我很满意,请老师保持",
"老师讲课富有激情,能感染学生,我觉得很好,暂时没有什么意见",
"老师专业知识扎实,讲解到位,对老师的教学我非常认可,没有建议",
"老师善于引导学生思考,启发性强,我认为一切都很好,请老师保持",
"老师课堂管理有序,教学效率高,我觉得没有什么需要改进的地方",
"老师授课风格独特,深受学生喜爱,我没有什么意见和建议",
"老师讲课深入浅出,通俗易懂,我认为非常好,希望老师继续保持",
"老师对学生一视同仁,公平公正,我很满意老师的教学方式",
"老师教学方法科学合理,效果突出,我觉得没有需要调整的地方",
"老师认真批改作业,及时反馈,对老师的工作我非常认可,请保持",
"老师课堂内容丰富多彩,讲解细致入微,我没有什么建议,一切都很好",
];
// 总体评价文案库
static const List<String> 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;
}
}

2
lib/widgets/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# Widgets directory
# This directory contains reusable widgets

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