😋 初始化仓库
45
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
44
android/app/build.gradle.kts
Normal 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 = "../.."
|
||||
}
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
54
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -0,0 +1,5 @@
|
||||
package meow.loveace.autojudge.loveace_autojudge
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal 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>
|
||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||
18
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal 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
@@ -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)
|
||||
}
|
||||
3
android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
26
android/settings.gradle.kts
Normal 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
|
After Width: | Height: | Size: 290 KiB |
BIN
fonts/MiSans-Regular.otf
Normal file
34
ios/.gitignore
vendored
Normal 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
|
||||
26
ios/Flutter/AppFrameworkInfo.plist
Normal 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>
|
||||
1
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
1
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
616
ios/Runner.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
101
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal 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>
|
||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
13
ios/Runner/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal 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.
|
||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal 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
@@ -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>
|
||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
12
ios/RunnerTests/RunnerTests.swift
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
||||
# Models directory
|
||||
# This directory contains data models for the application
|
||||
111
lib/models/concurrent_task.dart
Normal 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
@@ -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;
|
||||
}
|
||||
54
lib/models/evaluation_history.dart
Normal 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;
|
||||
}
|
||||
89
lib/models/evaluation_result.dart
Normal 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)';
|
||||
}
|
||||
}
|
||||
85
lib/models/login_status.dart
Normal 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
@@ -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';
|
||||
224
lib/models/questionnaire.dart
Normal 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})';
|
||||
}
|
||||
}
|
||||
78
lib/models/user_credentials.dart
Normal 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
@@ -0,0 +1,2 @@
|
||||
# Providers directory
|
||||
# This directory contains state management providers
|
||||
273
lib/providers/auth_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
935
lib/providers/evaluation_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
390
lib/providers/theme_provider.dart
Normal 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
@@ -0,0 +1,2 @@
|
||||
# Screens directory
|
||||
# This directory contains UI screens
|
||||
272
lib/screens/home_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
252
lib/screens/login_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
647
lib/screens/progress_screen.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
368
lib/screens/settings_screen.dart
Normal 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
@@ -0,0 +1,2 @@
|
||||
# Services directory
|
||||
# This directory contains business logic services
|
||||
275
lib/services/app_initialization_service.dart
Normal 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'
|
||||
')';
|
||||
}
|
||||
}
|
||||
539
lib/services/aufe_connection.dart
Normal 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';
|
||||
}
|
||||
431
lib/services/evaluation_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
lib/services/http_client.dart
Normal 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;
|
||||
}
|
||||
203
lib/services/notification_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
412
lib/services/questionnaire_parser.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
325
lib/services/storage_service.dart
Normal 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
@@ -0,0 +1,2 @@
|
||||
# Utils directory
|
||||
# This directory contains utility functions and helpers
|
||||
239
lib/utils/app_logger.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
174
lib/utils/error_handler.dart
Normal 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
@@ -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();
|
||||
}
|
||||
}
|
||||
76
lib/utils/retry_handler.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
263
lib/utils/session_manager.dart
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
194
lib/utils/text_generator.dart
Normal 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
@@ -0,0 +1,2 @@
|
||||
# Widgets directory
|
||||
# This directory contains reusable widgets
|
||||