😋 初始化仓库
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
|
||||||