😋 初始化仓库

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

137
lib/main.dart Normal file
View File

@@ -0,0 +1,137 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/auth_provider.dart';
import 'providers/evaluation_provider.dart';
import 'providers/theme_provider.dart';
import 'services/notification_service.dart';
import 'screens/login_screen.dart';
import 'screens/home_screen.dart';
import 'widgets/loading_indicator.dart';
import 'utils/error_handler.dart';
import 'utils/app_logger.dart';
void main() async {
// Run app with global error handling
await ErrorHandler.runAppWithErrorHandling(() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize logger
final logger = AppLogger();
logger.initialize();
logger.info('Application starting...');
// Initialize notification service
final notificationService = NotificationService();
await notificationService.initialize();
runApp(MyApp(notificationService: notificationService));
});
}
class MyApp extends StatelessWidget {
final NotificationService notificationService;
const MyApp({super.key, required this.notificationService});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProxyProvider<AuthProvider, EvaluationProvider>(
create: (context) {
// Create initial provider without connection
// Connection will be set later when user logs in
return EvaluationProvider(
service: null,
notificationService: notificationService,
);
},
update: (context, authProvider, previous) {
// Keep the same provider instance and just update connection
if (previous != null) {
previous.setConnection(authProvider.connection);
return previous;
}
return EvaluationProvider(
service: null,
notificationService: notificationService,
);
},
),
],
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
// Use MiSans font on Windows platform
final fontFamily = Platform.isWindows ? 'MiSans' : null;
return MaterialApp(
title: '自动评教系统',
debugShowCheckedModeBanner: false,
theme: themeProvider.lightTheme.copyWith(
textTheme: themeProvider.lightTheme.textTheme.apply(
fontFamily: fontFamily,
),
),
darkTheme: themeProvider.darkTheme.copyWith(
textTheme: themeProvider.darkTheme.textTheme.apply(
fontFamily: fontFamily,
),
),
themeMode: themeProvider.themeMode,
home: const AppInitializer(),
);
},
),
);
}
}
/// App initializer widget
///
/// Handles initial app setup and session restoration
class AppInitializer extends StatefulWidget {
const AppInitializer({super.key});
@override
State<AppInitializer> createState() => _AppInitializerState();
}
class _AppInitializerState extends State<AppInitializer> {
bool _isInitializing = true;
bool _hasSession = false;
@override
void initState() {
super.initState();
// Defer initialization until after the first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
_initialize();
});
}
Future<void> _initialize() async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
// Try to restore session
final restored = await authProvider.restoreSession();
if (mounted) {
setState(() {
_isInitializing = false;
_hasSession = restored;
});
}
}
@override
Widget build(BuildContext context) {
if (_isInitializing) {
return const Scaffold(body: LoadingIndicator(message: '正在初始化...'));
}
return _hasSession ? const HomeScreen() : const LoginScreen();
}
}

2
lib/models/.gitkeep Normal file
View File

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

View File

@@ -0,0 +1,111 @@
import 'course.dart';
/// Concurrent evaluation task status
enum TaskStatus {
waiting, // 等待开始
preparing, // 准备评教(访问页面、解析问卷)
countdown, // 倒计时等待
submitting, // 提交中
verifying, // 验证中
completed, // 完成
failed, // 失败
}
/// Concurrent evaluation task
class ConcurrentTask {
final int taskId;
final Course course;
TaskStatus status;
String? statusMessage;
int countdownRemaining;
int countdownTotal;
String? errorMessage;
DateTime? startTime;
DateTime? endTime;
ConcurrentTask({
required this.taskId,
required this.course,
this.status = TaskStatus.waiting,
this.statusMessage,
this.countdownRemaining = 0,
this.countdownTotal = 0,
this.errorMessage,
this.startTime,
this.endTime,
});
/// Get progress (0.0 to 1.0)
double get progress {
switch (status) {
case TaskStatus.waiting:
return 0.0;
case TaskStatus.preparing:
return 0.1;
case TaskStatus.countdown:
if (countdownTotal > 0) {
return 0.1 +
0.7 * (countdownTotal - countdownRemaining) / countdownTotal;
}
return 0.1;
case TaskStatus.submitting:
return 0.85;
case TaskStatus.verifying:
return 0.95;
case TaskStatus.completed:
case TaskStatus.failed:
return 1.0;
}
}
/// Get status display text
String get statusText {
if (statusMessage != null) return statusMessage!;
switch (status) {
case TaskStatus.waiting:
return '等待开始';
case TaskStatus.preparing:
return '准备评教';
case TaskStatus.countdown:
return '等待提交 (${countdownRemaining}s)';
case TaskStatus.submitting:
return '提交中';
case TaskStatus.verifying:
return '验证中';
case TaskStatus.completed:
return '完成';
case TaskStatus.failed:
return '失败';
}
}
/// Check if task is finished
bool get isFinished =>
status == TaskStatus.completed || status == TaskStatus.failed;
/// Check if task is successful
bool get isSuccess => status == TaskStatus.completed;
ConcurrentTask copyWith({
TaskStatus? status,
String? statusMessage,
int? countdownRemaining,
int? countdownTotal,
String? errorMessage,
DateTime? startTime,
DateTime? endTime,
}) {
return ConcurrentTask(
taskId: taskId,
course: course,
status: status ?? this.status,
statusMessage: statusMessage ?? this.statusMessage,
countdownRemaining: countdownRemaining ?? this.countdownRemaining,
countdownTotal: countdownTotal ?? this.countdownTotal,
errorMessage: errorMessage ?? this.errorMessage,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
);
}
}

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

@@ -0,0 +1,101 @@
/// Course data model representing a course that needs evaluation
class Course {
final String id;
final String name;
final String teacher;
final String evaluatedPeople;
final String evaluatedPeopleNumber;
final String coureSequenceNumber;
final String evaluationContentNumber;
final String questionnaireCode;
final String questionnaireName;
final bool isEvaluated;
Course({
required this.id,
required this.name,
required this.teacher,
required this.evaluatedPeople,
required this.evaluatedPeopleNumber,
required this.coureSequenceNumber,
required this.evaluationContentNumber,
required this.questionnaireCode,
required this.questionnaireName,
this.isEvaluated = false,
});
/// Create Course from JSON
factory Course.fromJson(Map<String, dynamic> json) {
return Course(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
teacher: json['teacher'] as String? ?? '',
evaluatedPeople: json['evaluatedPeople'] as String? ?? '',
evaluatedPeopleNumber: json['evaluatedPeopleNumber'] as String? ?? '',
coureSequenceNumber: json['coureSequenceNumber'] as String? ?? '',
evaluationContentNumber: json['evaluationContentNumber'] as String? ?? '',
questionnaireCode: json['questionnaireCode'] as String? ?? '',
questionnaireName: json['questionnaireName'] as String? ?? '',
isEvaluated: json['isEvaluated'] as bool? ?? false,
);
}
/// Convert Course to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'teacher': teacher,
'evaluatedPeople': evaluatedPeople,
'evaluatedPeopleNumber': evaluatedPeopleNumber,
'coureSequenceNumber': coureSequenceNumber,
'evaluationContentNumber': evaluationContentNumber,
'questionnaireCode': questionnaireCode,
'questionnaireName': questionnaireName,
'isEvaluated': isEvaluated,
};
}
/// Create a copy of Course with updated fields
Course copyWith({
String? id,
String? name,
String? teacher,
String? evaluatedPeople,
String? evaluatedPeopleNumber,
String? coureSequenceNumber,
String? evaluationContentNumber,
String? questionnaireCode,
String? questionnaireName,
bool? isEvaluated,
}) {
return Course(
id: id ?? this.id,
name: name ?? this.name,
teacher: teacher ?? this.teacher,
evaluatedPeople: evaluatedPeople ?? this.evaluatedPeople,
evaluatedPeopleNumber:
evaluatedPeopleNumber ?? this.evaluatedPeopleNumber,
coureSequenceNumber: coureSequenceNumber ?? this.coureSequenceNumber,
evaluationContentNumber:
evaluationContentNumber ?? this.evaluationContentNumber,
questionnaireCode: questionnaireCode ?? this.questionnaireCode,
questionnaireName: questionnaireName ?? this.questionnaireName,
isEvaluated: isEvaluated ?? this.isEvaluated,
);
}
@override
String toString() {
return 'Course(id: $id, name: $name, teacher: $teacher, isEvaluated: $isEvaluated)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Course && other.id == id;
}
@override
int get hashCode => id.hashCode;
}

View File

@@ -0,0 +1,54 @@
import 'course.dart';
/// Evaluation history record
class EvaluationHistory {
final String id;
final Course course;
final DateTime timestamp;
final bool success;
final String? errorMessage;
EvaluationHistory({
required this.id,
required this.course,
required this.timestamp,
required this.success,
this.errorMessage,
});
factory EvaluationHistory.fromJson(Map<String, dynamic> json) {
return EvaluationHistory(
id: json['id'] as String? ?? '',
course: Course.fromJson(json['course'] as Map<String, dynamic>),
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'] as String)
: DateTime.now(),
success: json['success'] as bool? ?? false,
errorMessage: json['errorMessage'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'course': course.toJson(),
'timestamp': timestamp.toIso8601String(),
'success': success,
'errorMessage': errorMessage,
};
}
@override
String toString() {
return 'EvaluationHistory(id: $id, course: ${course.name}, success: $success, timestamp: $timestamp)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is EvaluationHistory && other.id == id;
}
@override
int get hashCode => id.hashCode;
}

View File

@@ -0,0 +1,89 @@
import 'course.dart';
/// Result of a single course evaluation
class EvaluationResult {
final Course course;
final bool success;
final String? errorMessage;
final DateTime timestamp;
EvaluationResult({
required this.course,
required this.success,
this.errorMessage,
DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();
factory EvaluationResult.fromJson(Map<String, dynamic> json) {
return EvaluationResult(
course: Course.fromJson(json['course'] as Map<String, dynamic>),
success: json['success'] as bool? ?? false,
errorMessage: json['errorMessage'] as String?,
timestamp: json['timestamp'] != null
? DateTime.parse(json['timestamp'] as String)
: DateTime.now(),
);
}
Map<String, dynamic> toJson() {
return {
'course': course.toJson(),
'success': success,
'errorMessage': errorMessage,
'timestamp': timestamp.toIso8601String(),
};
}
@override
String toString() {
return 'EvaluationResult(course: ${course.name}, success: $success, errorMessage: $errorMessage)';
}
}
/// Result of batch evaluation
class BatchEvaluationResult {
final int total;
final int success;
final int failed;
final List<EvaluationResult> results;
final Duration duration;
BatchEvaluationResult({
required this.total,
required this.success,
required this.failed,
required this.results,
required this.duration,
});
factory BatchEvaluationResult.fromJson(Map<String, dynamic> json) {
return BatchEvaluationResult(
total: json['total'] as int? ?? 0,
success: json['success'] as int? ?? 0,
failed: json['failed'] as int? ?? 0,
results:
(json['results'] as List<dynamic>?)
?.map((e) => EvaluationResult.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
duration: Duration(milliseconds: json['durationMs'] as int? ?? 0),
);
}
Map<String, dynamic> toJson() {
return {
'total': total,
'success': success,
'failed': failed,
'results': results.map((e) => e.toJson()).toList(),
'durationMs': duration.inMilliseconds,
};
}
double get successRate => total > 0 ? success / total : 0.0;
@override
String toString() {
return 'BatchEvaluationResult(total: $total, success: $success, failed: $failed, duration: ${duration.inSeconds}s)';
}
}

View File

@@ -0,0 +1,85 @@
/// EC登录状态
class ECLoginStatus {
final bool success;
final bool failNotFoundTwfid;
final bool failNotFoundRsaKey;
final bool failNotFoundRsaExp;
final bool failNotFoundCsrfCode;
final bool failInvalidCredentials;
final bool failMaybeAttacked;
final bool failNetworkError;
final bool failUnknownError;
ECLoginStatus({
this.success = false,
this.failNotFoundTwfid = false,
this.failNotFoundRsaKey = false,
this.failNotFoundRsaExp = false,
this.failNotFoundCsrfCode = false,
this.failInvalidCredentials = false,
this.failMaybeAttacked = false,
this.failNetworkError = false,
this.failUnknownError = false,
});
bool get isSuccess => success;
bool get isFailed => !success;
String get errorMessage {
if (failNotFoundTwfid) return '未找到TwfID';
if (failNotFoundRsaKey) return '未找到RSA密钥';
if (failNotFoundRsaExp) return '未找到RSA指数';
if (failNotFoundCsrfCode) return '未找到CSRF代码';
if (failInvalidCredentials) return '用户名或密码错误';
if (failMaybeAttacked) return '可能受到攻击或需要验证码';
if (failNetworkError) return '网络连接错误';
if (failUnknownError) return '未知错误';
return '';
}
}
/// UAAP登录状态
class UAAPLoginStatus {
final bool success;
final bool failNotFoundLt;
final bool failNotFoundExecution;
final bool failInvalidCredentials;
final bool failNetworkError;
final bool failUnknownError;
UAAPLoginStatus({
this.success = false,
this.failNotFoundLt = false,
this.failNotFoundExecution = false,
this.failInvalidCredentials = false,
this.failNetworkError = false,
this.failUnknownError = false,
});
bool get isSuccess => success;
bool get isFailed => !success;
String get errorMessage {
if (failNotFoundLt) return '未找到lt参数';
if (failNotFoundExecution) return '未找到execution参数';
if (failInvalidCredentials) return '用户名或密码错误';
if (failNetworkError) return '网络连接错误';
if (failUnknownError) return '未知错误';
return '';
}
}
/// EC检查状态
class ECCheckStatus {
final bool loggedIn;
final bool failNetworkError;
final bool failUnknownError;
ECCheckStatus({
this.loggedIn = false,
this.failNetworkError = false,
this.failUnknownError = false,
});
bool get isLoggedIn => loggedIn;
}

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

@@ -0,0 +1,6 @@
/// Export all data models
export 'course.dart';
export 'questionnaire.dart';
export 'user_credentials.dart';
export 'evaluation_result.dart';
export 'evaluation_history.dart';

View File

@@ -0,0 +1,224 @@
/// Question type enum for text questions
enum QuestionType {
inspiration, // 启发类(包含"启发"关键词)
suggestion, // 建议类(包含"建议"、"意见"关键词)
overall, // 总体评价zgpj
general, // 通用类型
}
/// Metadata for questionnaire
class QuestionnaireMetadata {
final String title;
final String evaluatedPerson;
final String evaluationContent;
final String tokenValue;
final String questionnaireCode;
final String evaluatedPeopleNumber;
QuestionnaireMetadata({
required this.title,
required this.evaluatedPerson,
required this.evaluationContent,
required this.tokenValue,
required this.questionnaireCode,
required this.evaluatedPeopleNumber,
});
factory QuestionnaireMetadata.fromJson(Map<String, dynamic> json) {
return QuestionnaireMetadata(
title: json['title'] as String? ?? '',
evaluatedPerson: json['evaluatedPerson'] as String? ?? '',
evaluationContent: json['evaluationContent'] as String? ?? '',
tokenValue: json['tokenValue'] as String? ?? '',
questionnaireCode: json['questionnaireCode'] as String? ?? '',
evaluatedPeopleNumber: json['evaluatedPeopleNumber'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'title': title,
'evaluatedPerson': evaluatedPerson,
'evaluationContent': evaluationContent,
'tokenValue': tokenValue,
'questionnaireCode': questionnaireCode,
'evaluatedPeopleNumber': evaluatedPeopleNumber,
};
}
}
/// Radio option for single-choice questions
class RadioOption {
final String label; // 如"(A) 非常满意"
final String value; // 如"5_1"5分×100%
final double score; // 解析后的分数
final double weight; // 解析后的权重
RadioOption({
required this.label,
required this.value,
required this.score,
required this.weight,
});
factory RadioOption.fromJson(Map<String, dynamic> json) {
return RadioOption(
label: json['label'] as String? ?? '',
value: json['value'] as String? ?? '',
score: (json['score'] as num?)?.toDouble() ?? 0.0,
weight: (json['weight'] as num?)?.toDouble() ?? 0.0,
);
}
Map<String, dynamic> toJson() {
return {'label': label, 'value': value, 'score': score, 'weight': weight};
}
@override
String toString() {
return 'RadioOption(label: $label, value: $value, score: $score, weight: $weight)';
}
}
/// Radio question (single-choice question)
class RadioQuestion {
final String key; // 动态key如"0000000401"
final String questionText; // 题目文本
final List<RadioOption> options;
final String category; // 如"师德师风"、"教学内容"
RadioQuestion({
required this.key,
required this.questionText,
required this.options,
this.category = '',
});
factory RadioQuestion.fromJson(Map<String, dynamic> json) {
return RadioQuestion(
key: json['key'] as String? ?? '',
questionText: json['questionText'] as String? ?? '',
options:
(json['options'] as List<dynamic>?)
?.map((e) => RadioOption.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
category: json['category'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'key': key,
'questionText': questionText,
'options': options.map((e) => e.toJson()).toList(),
'category': category,
};
}
@override
String toString() {
return 'RadioQuestion(key: $key, questionText: $questionText, category: $category, options: ${options.length})';
}
}
/// Text question (open-ended question)
class TextQuestion {
final String key; // 动态key或"zgpj"
final String questionText; // 题目文本
final QuestionType type; // 通过关键词识别的类型
final bool isRequired; // 是否必填
TextQuestion({
required this.key,
required this.questionText,
required this.type,
this.isRequired = false,
});
factory TextQuestion.fromJson(Map<String, dynamic> json) {
return TextQuestion(
key: json['key'] as String? ?? '',
questionText: json['questionText'] as String? ?? '',
type: QuestionType.values.firstWhere(
(e) => e.toString() == 'QuestionType.${json['type']}',
orElse: () => QuestionType.general,
),
isRequired: json['isRequired'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'key': key,
'questionText': questionText,
'type': type.toString().split('.').last,
'isRequired': isRequired,
};
}
@override
String toString() {
return 'TextQuestion(key: $key, questionText: $questionText, type: $type, isRequired: $isRequired)';
}
}
/// Complete questionnaire structure
class Questionnaire {
final QuestionnaireMetadata metadata;
final List<RadioQuestion> radioQuestions;
final List<TextQuestion> textQuestions;
final String tokenValue;
final String questionnaireCode;
final String evaluationContent;
final String evaluatedPeopleNumber;
Questionnaire({
required this.metadata,
required this.radioQuestions,
required this.textQuestions,
required this.tokenValue,
required this.questionnaireCode,
required this.evaluationContent,
required this.evaluatedPeopleNumber,
});
factory Questionnaire.fromJson(Map<String, dynamic> json) {
return Questionnaire(
metadata: QuestionnaireMetadata.fromJson(
json['metadata'] as Map<String, dynamic>? ?? {},
),
radioQuestions:
(json['radioQuestions'] as List<dynamic>?)
?.map((e) => RadioQuestion.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
textQuestions:
(json['textQuestions'] as List<dynamic>?)
?.map((e) => TextQuestion.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
tokenValue: json['tokenValue'] as String? ?? '',
questionnaireCode: json['questionnaireCode'] as String? ?? '',
evaluationContent: json['evaluationContent'] as String? ?? '',
evaluatedPeopleNumber: json['evaluatedPeopleNumber'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'metadata': metadata.toJson(),
'radioQuestions': radioQuestions.map((e) => e.toJson()).toList(),
'textQuestions': textQuestions.map((e) => e.toJson()).toList(),
'tokenValue': tokenValue,
'questionnaireCode': questionnaireCode,
'evaluationContent': evaluationContent,
'evaluatedPeopleNumber': evaluatedPeopleNumber,
};
}
@override
String toString() {
return 'Questionnaire(radioQuestions: ${radioQuestions.length}, textQuestions: ${textQuestions.length})';
}
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// User credentials for authentication
class UserCredentials {
final String userId;
final String ecPassword;
final String password;
UserCredentials({
required this.userId,
required this.ecPassword,
required this.password,
});
factory UserCredentials.fromJson(Map<String, dynamic> json) {
return UserCredentials(
userId: json['userId'] as String? ?? '',
ecPassword: json['ecPassword'] as String? ?? '',
password: json['password'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {'userId': userId, 'ecPassword': ecPassword, 'password': password};
}
/// Save credentials securely using flutter_secure_storage
Future<void> saveSecurely() async {
const storage = FlutterSecureStorage();
await storage.write(key: 'user_id', value: userId);
await storage.write(key: 'ec_password', value: ecPassword);
await storage.write(key: 'password', value: password);
}
/// Load credentials from secure storage
static Future<UserCredentials?> loadSecurely() async {
const storage = FlutterSecureStorage();
final userId = await storage.read(key: 'user_id');
final ecPassword = await storage.read(key: 'ec_password');
final password = await storage.read(key: 'password');
if (userId == null || ecPassword == null || password == null) {
return null;
}
return UserCredentials(
userId: userId,
ecPassword: ecPassword,
password: password,
);
}
/// Clear credentials from secure storage
static Future<void> clearSecurely() async {
const storage = FlutterSecureStorage();
await storage.delete(key: 'user_id');
await storage.delete(key: 'ec_password');
await storage.delete(key: 'password');
}
@override
String toString() {
return 'UserCredentials(userId: $userId)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UserCredentials &&
other.userId == userId &&
other.ecPassword == ecPassword &&
other.password == password;
}
@override
int get hashCode => Object.hash(userId, ecPassword, password);
}

2
lib/providers/.gitkeep Normal file
View File

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

View File

@@ -0,0 +1,273 @@
import 'package:flutter/foundation.dart';
import '../models/user_credentials.dart';
import '../services/aufe_connection.dart';
/// Authentication state enum
enum AuthState { initial, loading, authenticated, unauthenticated, error }
/// Provider for managing authentication state and user sessions
///
/// Handles login, logout, session checking, and credential management
/// Uses ChangeNotifier to notify listeners of state changes
///
/// Usage example:
/// ```dart
/// final authProvider = Provider.of<AuthProvider>(context);
///
/// // Login
/// await authProvider.login(
/// userId: '学号',
/// ecPassword: 'EC密码',
/// password: 'UAAP密码',
/// );
///
/// // Check session
/// final isValid = await authProvider.checkSession();
///
/// // Logout
/// await authProvider.logout();
/// ```
class AuthProvider extends ChangeNotifier {
AUFEConnection? _connection;
AuthState _state = AuthState.initial;
String? _errorMessage;
UserCredentials? _credentials;
/// Get current authentication state
AuthState get state => _state;
/// Get current error message (if any)
String? get errorMessage => _errorMessage;
/// Get current connection instance
AUFEConnection? get connection => _connection;
/// Get current user credentials
UserCredentials? get credentials => _credentials;
/// Check if user is authenticated
bool get isAuthenticated => _state == AuthState.authenticated;
/// Login with user credentials
///
/// Creates AUFEConnection and performs both EC and UAAP login
/// Saves credentials securely on successful login
///
/// [userId] - Student ID
/// [ecPassword] - EC system password
/// [password] - UAAP system password
///
/// Returns true if login succeeds, false otherwise
Future<bool> login({
required String userId,
required String ecPassword,
required String password,
}) async {
try {
print('🔐 Starting login process...');
print('🔐 User ID: $userId');
_setState(AuthState.loading);
_errorMessage = null;
// Create credentials
final credentials = UserCredentials(
userId: userId,
ecPassword: ecPassword,
password: password,
);
// Create connection
print('🔐 Creating AUFEConnection...');
final connection = AUFEConnection(
userId: userId,
ecPassword: ecPassword,
password: password,
);
// Initialize HTTP client
print('🔐 Starting HTTP client...');
connection.startClient();
// Perform EC login
print('🔐 Performing EC login...');
final ecLoginStatus = await connection.ecLogin();
print('🔐 EC login result: ${ecLoginStatus.success}');
if (!ecLoginStatus.success) {
_errorMessage = _getEcLoginErrorMessage(ecLoginStatus);
print('❌ EC login failed: $_errorMessage');
_setState(AuthState.error);
await connection.close();
return false;
}
// Perform UAAP login
print('🔐 Performing UAAP login...');
final uaapLoginStatus = await connection.uaapLogin();
print('🔐 UAAP login result: ${uaapLoginStatus.success}');
if (!uaapLoginStatus.success) {
_errorMessage = _getUaapLoginErrorMessage(uaapLoginStatus);
print('❌ UAAP login failed: $_errorMessage');
_setState(AuthState.error);
await connection.close();
return false;
}
// Save credentials securely
print('🔐 Saving credentials...');
await credentials.saveSecurely();
// Update state
_connection = connection;
_credentials = credentials;
_setState(AuthState.authenticated);
print('✅ Login successful!');
return true;
} catch (e, stackTrace) {
_errorMessage = '登录过程出错: $e';
print('❌ Login error: $e');
print('❌ Stack trace: $stackTrace');
_setState(AuthState.error);
return false;
}
}
/// Logout and clear all session data
///
/// Closes connection, clears credentials from secure storage,
/// and resets authentication state
Future<void> logout() async {
try {
// Close connection
if (_connection != null) {
await _connection!.close();
_connection = null;
}
// Clear credentials from secure storage
await UserCredentials.clearSecurely();
// Reset state
_credentials = null;
_errorMessage = null;
_setState(AuthState.unauthenticated);
} catch (e) {
debugPrint('Error during logout: $e');
// Still reset state even if cleanup fails
_connection = null;
_credentials = null;
_setState(AuthState.unauthenticated);
}
}
/// Check if current session is still valid
///
/// Performs health check on the connection
/// If session is invalid, updates state to unauthenticated
///
/// Returns true if session is valid, false otherwise
Future<bool> checkSession() async {
if (_connection == null || _state != AuthState.authenticated) {
_setState(AuthState.unauthenticated);
return false;
}
try {
final isHealthy = await _connection!.healthCheck();
if (!isHealthy) {
_errorMessage = '会话已过期,请重新登录';
_setState(AuthState.unauthenticated);
return false;
}
return true;
} catch (e) {
_errorMessage = '检查会话状态失败: $e';
_setState(AuthState.error);
return false;
}
}
/// Attempt to restore session from saved credentials
///
/// Loads credentials from secure storage and attempts to login
/// Useful for auto-login on app startup
///
/// Returns true if session restored successfully, false otherwise
Future<bool> restoreSession() async {
try {
_setState(AuthState.loading);
// Load saved credentials
final credentials = await UserCredentials.loadSecurely();
if (credentials == null) {
_setState(AuthState.unauthenticated);
return false;
}
// Attempt login with saved credentials
return await login(
userId: credentials.userId,
ecPassword: credentials.ecPassword,
password: credentials.password,
);
} catch (e) {
_errorMessage = '恢复会话失败: $e';
_setState(AuthState.unauthenticated);
return false;
}
}
/// Update authentication state and notify listeners
void _setState(AuthState newState) {
_state = newState;
notifyListeners();
}
/// Get user-friendly error message for EC login status
String _getEcLoginErrorMessage(dynamic status) {
if (status.failInvalidCredentials) {
return 'EC系统用户名或密码错误';
} else if (status.failNotFoundTwfid) {
return '无法获取TwfID请稍后重试';
} else if (status.failNotFoundRsaKey) {
return '无法获取RSA密钥请稍后重试';
} else if (status.failNotFoundRsaExp) {
return '无法获取RSA指数请稍后重试';
} else if (status.failNotFoundCsrfCode) {
return '无法获取CSRF代码请稍后重试';
} else if (status.failMaybeAttacked) {
return '登录频繁,请稍后重试';
} else if (status.failNetworkError) {
return 'EC系统网络连接失败';
} else {
return 'EC系统登录失败';
}
}
/// Get user-friendly error message for UAAP login status
String _getUaapLoginErrorMessage(dynamic status) {
if (status.failInvalidCredentials) {
return 'UAAP系统用户名或密码错误';
} else if (status.failNotFoundLt) {
return '无法获取lt参数请稍后重试';
} else if (status.failNotFoundExecution) {
return '无法获取execution参数请稍后重试';
} else if (status.failNetworkError) {
return 'UAAP系统网络连接失败';
} else {
return 'UAAP系统登录失败';
}
}
@override
void dispose() {
// Close connection when provider is disposed
_connection?.close();
super.dispose();
}
}

View File

@@ -0,0 +1,935 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/course.dart';
import '../models/evaluation_result.dart';
import '../models/evaluation_history.dart';
import '../models/concurrent_task.dart';
import '../services/evaluation_service.dart';
import '../services/notification_service.dart';
import '../services/storage_service.dart';
import '../services/questionnaire_parser.dart';
import '../utils/text_generator.dart';
/// Evaluation state enum
enum EvaluationState { idle, loading, evaluating, completed, error }
/// Provider for managing course evaluation state and operations
///
/// Handles loading courses, batch evaluation, progress tracking,
/// and notification management
///
/// Usage example:
/// ```dart
/// final evaluationProvider = Provider.of<EvaluationProvider>(context);
///
/// // Load courses
/// await evaluationProvider.loadCourses();
///
/// // Start batch evaluation
/// await evaluationProvider.startBatchEvaluation();
///
/// // Retry failed courses
/// await evaluationProvider.retryFailed();
/// ```
class EvaluationProvider extends ChangeNotifier {
EvaluationService? _service;
final NotificationService _notificationService;
final StorageService _storageService;
List<Course> _courses = [];
EvaluationState _state = EvaluationState.idle;
BatchEvaluationResult? _lastResult;
String? _errorMessage;
List<EvaluationHistory> _evaluationHistory = [];
// Progress tracking
int _currentProgress = 0;
int _totalProgress = 0;
Course? _currentCourse;
String? _currentStatus;
final List<String> _logs = [];
// Countdown tracking
int _countdownRemaining = 0;
int _countdownTotal = 0;
// Concurrent evaluation tracking
final List<ConcurrentTask> _concurrentTasks = [];
bool _isConcurrentMode = false;
EvaluationProvider({
EvaluationService? service,
required NotificationService notificationService,
StorageService? storageService,
}) : _service = service,
_notificationService = notificationService,
_storageService = storageService ?? StorageService() {
_loadEvaluationHistory();
}
dynamic _connection;
/// Set the connection to use for fetching courses and create evaluation service
void setConnection(dynamic connection) {
_connection = connection;
if (connection != null) {
_service = EvaluationService(
connection: connection,
parser: QuestionnaireParser(),
textGenerator: TextGenerator(),
);
}
}
/// Get current evaluation state
EvaluationState get state => _state;
/// Get list of courses
List<Course> get courses => _courses;
/// Get last batch evaluation result
BatchEvaluationResult? get lastResult => _lastResult;
/// Get current error message (if any)
String? get errorMessage => _errorMessage;
/// Get current progress (0.0 to 1.0)
double get progress =>
_totalProgress > 0 ? _currentProgress / _totalProgress : 0.0;
/// Get current progress count
int get currentProgress => _currentProgress;
/// Get total progress count
int get totalProgress => _totalProgress;
/// Get current course being evaluated
Course? get currentCourse => _currentCourse;
/// Get current status message
String? get currentStatus => _currentStatus;
/// Get evaluation logs
List<String> get logs => List.unmodifiable(_logs);
/// Get countdown remaining seconds
int get countdownRemaining => _countdownRemaining;
/// Get countdown total seconds
int get countdownTotal => _countdownTotal;
/// Get countdown progress (0.0 to 1.0)
double get countdownProgress => _countdownTotal > 0
? (_countdownTotal - _countdownRemaining) / _countdownTotal
: 0.0;
/// Get concurrent tasks
List<ConcurrentTask> get concurrentTasks =>
List.unmodifiable(_concurrentTasks);
/// Check if in concurrent mode
bool get isConcurrentMode => _isConcurrentMode;
/// Add a log entry
void _addLog(String message, {bool updateLast = false}) {
final timestamp = DateTime.now().toString().substring(11, 19);
final logEntry = '[$timestamp] $message';
if (updateLast && _logs.isNotEmpty) {
// Update the last log entry
_logs[_logs.length - 1] = logEntry;
} else {
// Add new log entry
_logs.add(logEntry);
// Keep only last 100 logs
if (_logs.length > 100) {
_logs.removeAt(0);
}
}
notifyListeners();
}
/// Clear logs
void clearLogs() {
_logs.clear();
notifyListeners();
}
/// Get pending courses (not yet evaluated)
List<Course> get pendingCourses =>
_courses.where((c) => !c.isEvaluated).toList();
/// Get evaluated courses
List<Course> get evaluatedCourses =>
_courses.where((c) => c.isEvaluated).toList();
/// Get failed courses from last batch evaluation
List<Course> get failedCourses {
if (_lastResult == null) return [];
return _lastResult!.results
.where((r) => !r.success)
.map((r) => r.course)
.toList();
}
/// Get evaluation history
List<EvaluationHistory> get evaluationHistory => _evaluationHistory;
/// Load evaluation history from storage
Future<void> _loadEvaluationHistory() async {
try {
_evaluationHistory = await _storageService.loadEvaluationHistory();
notifyListeners();
} catch (e) {
debugPrint('Failed to load evaluation history: $e');
}
}
/// Refresh evaluation history from storage
Future<void> refreshEvaluationHistory() async {
await _loadEvaluationHistory();
}
/// Clear evaluation history
Future<void> clearEvaluationHistory() async {
try {
await _storageService.clearEvaluationHistory();
_evaluationHistory = [];
notifyListeners();
} catch (e) {
debugPrint('Failed to clear evaluation history: $e');
}
}
/// Save evaluation results to history
Future<void> _saveEvaluationResults(List<EvaluationResult> results) async {
try {
final histories = results.map((result) {
return EvaluationHistory(
id: '${result.course.id}_${result.timestamp.millisecondsSinceEpoch}',
course: result.course,
timestamp: result.timestamp,
success: result.success,
errorMessage: result.errorMessage,
);
}).toList();
await _storageService.saveEvaluationHistories(histories);
await _loadEvaluationHistory();
} catch (e) {
debugPrint('Failed to save evaluation results: $e');
}
}
/// Load courses from the server
///
/// Fetches the list of courses that need evaluation
/// Updates state and notifies listeners
///
/// Returns true if successful, false otherwise
Future<bool> loadCourses() async {
print('📚 loadCourses called');
print('📚 _connection: $_connection');
if (_connection == null) {
_errorMessage = '未设置连接';
_setState(EvaluationState.error);
print('❌ No connection set');
return false;
}
try {
print('📚 Setting state to loading...');
_setState(EvaluationState.loading);
_errorMessage = null;
print('📚 Calling _connection.fetchCourseList()...');
final courses = await _connection.fetchCourseList();
print('📚 Received ${courses.length} courses');
_courses = courses;
_setState(EvaluationState.idle);
return true;
} catch (e, stackTrace) {
_errorMessage = '加载课程列表失败: $e';
_setState(EvaluationState.error);
print('❌ Error loading courses: $e');
print('❌ Stack trace: $stackTrace');
return false;
}
}
/// Start batch evaluation of all pending courses
///
/// Evaluates all courses that haven't been evaluated yet
/// Shows notifications for progress and completion
/// Updates state and progress in real-time
///
/// Returns the batch evaluation result
Future<BatchEvaluationResult?> startBatchEvaluation() async {
if (_service == null) {
_errorMessage = '评教服务未初始化';
_setState(EvaluationState.error);
return null;
}
try {
_setState(EvaluationState.evaluating);
_errorMessage = null;
// Get pending courses
final pending = pendingCourses;
if (pending.isEmpty) {
_errorMessage = '没有待评课程';
_setState(EvaluationState.idle);
return null;
}
// Initialize progress
_currentProgress = 0;
_totalProgress = pending.length;
_currentCourse = null;
_currentStatus = null;
notifyListeners();
// Show start notification
await _notificationService.showBatchStartNotification(_totalProgress);
// Clear previous logs
_logs.clear();
_addLog('开始批量评教,共 $_totalProgress 门课程');
// Start batch evaluation with custom logic
final results = <EvaluationResult>[];
final startTime = DateTime.now();
for (int i = 0; i < pending.length; i++) {
final course = pending[i];
_currentProgress = i;
_currentCourse = course;
_currentStatus = '准备评教';
notifyListeners();
_addLog(
'❤ Created By LoveACE Team, 🌧 Powered By Sibuxiangx & Flutter',
);
_addLog('开始评教: ${course.name} (${course.teacher})');
// 1. Prepare evaluation
_currentStatus = '访问评价页面';
notifyListeners();
_addLog('📝 ${course.name}: 访问评价页面');
final formData = await _service!.prepareEvaluation(
course,
totalCourses: _totalProgress,
);
if (formData == null) {
final result = EvaluationResult(
course: course,
success: false,
errorMessage: '无法访问评价页面',
);
results.add(result);
_addLog('${course.name}: 访问失败,任务中断');
// Stop on error
_currentProgress = i + 1;
break;
}
_currentStatus = '解析问卷';
_addLog('📝 ${course.name}: 解析问卷');
_currentStatus = '生成答案';
_addLog('📝 ${course.name}: 生成答案');
// 2. Countdown (140 seconds)
_currentStatus = '等待提交';
_countdownTotal = 140;
for (int countdown = 140; countdown > 0; countdown--) {
_countdownRemaining = countdown;
notifyListeners();
await Future.delayed(const Duration(seconds: 1));
}
_countdownRemaining = 0;
_countdownTotal = 0;
// 3. Submit evaluation
_currentStatus = '提交评价';
notifyListeners();
_addLog('📝 ${course.name}: 提交评价');
final result = await _service!.submitEvaluation(course, formData);
results.add(result);
if (!result.success) {
_addLog('${course.name}: 评教失败,任务中断');
_currentProgress = i + 1;
break;
}
// 4. Verify evaluation
_currentStatus = '验证结果';
_addLog('📝 ${course.name}: 验证结果');
final updatedCourses = await _connection!.fetchCourseList();
final updatedCourse = updatedCourses.firstWhere(
(c) => c.id == course.id,
orElse: () => course,
);
if (!updatedCourse.isEvaluated) {
results[results.length - 1] = EvaluationResult(
course: course,
success: false,
errorMessage: '评教未生效,服务器未确认',
);
_addLog('${course.name}: 评教未生效,任务中断');
_currentProgress = i + 1;
break;
}
_addLog('${course.name}: 评教完成');
_currentProgress = i + 1;
_currentStatus = '评教完成';
notifyListeners();
// Small delay between courses
if (i < pending.length - 1) {
await Future.delayed(const Duration(milliseconds: 500));
}
}
// Calculate statistics
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
final result = BatchEvaluationResult(
total: results.length,
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
// Save result
_lastResult = result;
// Save evaluation results to history
await _saveEvaluationResults(result.results);
// Add completion log
if (result.failed > 0) {
_addLog(
'❌ 批量评教中断: 成功 ${result.success}/${result.total},失败 ${result.failed}',
);
} else {
_addLog('✅ 批量评教完成: 全部 ${result.total} 门课程评教成功');
}
// Show completion notification
await _notificationService.showCompletionNotification(
success: result.success,
failed: result.failed,
total: result.total,
);
// Reload courses to update evaluation status
_addLog('刷新课程列表...');
await loadCourses();
_addLog('课程列表已更新');
_setState(EvaluationState.completed);
return result;
} catch (e) {
_errorMessage = '批量评教失败: $e';
_setState(EvaluationState.error);
// Show error notification
await _notificationService.showErrorNotification(_errorMessage!);
return null;
}
}
/// Retry evaluation for failed courses
///
/// Re-evaluates only the courses that failed in the last batch evaluation
/// Useful for handling temporary network issues or server errors
///
/// Returns the batch evaluation result for retry attempt
Future<BatchEvaluationResult?> retryFailed() async {
if (_service == null) {
_errorMessage = '评教服务未初始化';
_setState(EvaluationState.error);
return null;
}
if (_lastResult == null || failedCourses.isEmpty) {
_errorMessage = '没有失败的课程需要重试';
return null;
}
try {
_setState(EvaluationState.evaluating);
_errorMessage = null;
final failedList = failedCourses;
// Initialize progress
_currentProgress = 0;
_totalProgress = failedList.length;
_currentCourse = null;
_currentStatus = null;
notifyListeners();
// Show start notification
await _notificationService.showBatchStartNotification(_totalProgress);
// Evaluate each failed course
final results = <EvaluationResult>[];
final startTime = DateTime.now();
for (int i = 0; i < failedList.length; i++) {
final course = failedList[i];
// Update progress
_currentProgress = i + 1;
_currentCourse = course;
_currentStatus = '正在重试...';
notifyListeners();
// Update notification
await _notificationService.updateProgressNotification(
current: i + 1,
total: _totalProgress,
courseName: course.name,
);
// Evaluate course
final result = await _service!.evaluateCourse(
course,
onStatusChange: (status) {
_currentStatus = status;
notifyListeners();
},
);
results.add(result);
// Small delay between evaluations
if (i < failedList.length - 1) {
await Future.delayed(const Duration(milliseconds: 500));
}
}
// Calculate statistics
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
final retryResult = BatchEvaluationResult(
total: failedList.length,
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
// Update last result
_lastResult = retryResult;
// Save retry results to history
await _saveEvaluationResults(results);
// Show completion notification
await _notificationService.showCompletionNotification(
success: successCount,
failed: failedCount,
total: failedList.length,
);
// Reload courses
await loadCourses();
_setState(EvaluationState.completed);
return retryResult;
} catch (e) {
_errorMessage = '重试失败: $e';
_setState(EvaluationState.error);
// Show error notification
await _notificationService.showErrorNotification(_errorMessage!);
return null;
}
}
/// Evaluate a single course
///
/// [course] - The course to evaluate
///
/// Returns the evaluation result
Future<EvaluationResult?> evaluateSingleCourse(Course course) async {
if (_service == null) {
_errorMessage = '评教服务未初始化';
_setState(EvaluationState.error);
return null;
}
try {
_setState(EvaluationState.evaluating);
_errorMessage = null;
_currentProgress = 0;
_totalProgress = 1;
_currentCourse = course;
_currentStatus = '正在评教...';
notifyListeners();
final result = await _service!.evaluateCourse(
course,
onStatusChange: (status) {
_currentStatus = status;
notifyListeners();
},
);
// Save single evaluation result to history
await _saveEvaluationResults([result]);
if (result.success) {
// Reload courses to update status
await loadCourses();
}
_setState(EvaluationState.completed);
return result;
} catch (e) {
_errorMessage = '评教失败: $e';
_setState(EvaluationState.error);
return null;
}
}
/// Clear last evaluation result
void clearLastResult() {
_lastResult = null;
_currentProgress = 0;
_totalProgress = 0;
_currentCourse = null;
_currentStatus = null;
notifyListeners();
}
/// Reset provider state
void reset() {
_courses = [];
_state = EvaluationState.idle;
_lastResult = null;
_errorMessage = null;
_currentProgress = 0;
_totalProgress = 0;
_currentCourse = null;
_currentStatus = null;
_logs.clear();
notifyListeners();
}
/// Update evaluation state and notify listeners
void _setState(EvaluationState newState) {
_state = newState;
notifyListeners();
}
/// Start concurrent batch evaluation
///
/// Evaluates all pending courses concurrently with 6-second intervals
/// Each task runs independently with its own 140-second countdown
///
/// Returns the batch evaluation result
Future<BatchEvaluationResult?> startConcurrentBatchEvaluation() async {
if (_service == null) {
_errorMessage = '评教服务未初始化';
_setState(EvaluationState.error);
return null;
}
try {
_setState(EvaluationState.evaluating);
_errorMessage = null;
_isConcurrentMode = true;
// Get pending courses
final pending = pendingCourses;
if (pending.isEmpty) {
_errorMessage = '没有待评课程';
_setState(EvaluationState.idle);
_isConcurrentMode = false;
return null;
}
// Initialize
_currentProgress = 0;
_totalProgress = pending.length;
_concurrentTasks.clear();
_logs.clear();
notifyListeners();
// Show start notification
await _notificationService.showBatchStartNotification(_totalProgress);
_addLog('开始并发批量评教,共 $_totalProgress 门课程');
// Create tasks
for (int i = 0; i < pending.length; i++) {
_concurrentTasks.add(
ConcurrentTask(
taskId: i + 1,
course: pending[i],
status: TaskStatus.waiting,
),
);
}
notifyListeners();
// Start tasks with 6-second intervals
final taskFutures = <Future<EvaluationResult>>[];
final startTime = DateTime.now();
for (int i = 0; i < _concurrentTasks.length; i++) {
final task = _concurrentTasks[i];
// Wait 6 seconds before starting next task (except first one)
if (i > 0) {
_addLog('等待 6 秒后启动下一个任务... (${i + 1}/$_totalProgress)');
await Future.delayed(const Duration(seconds: 6));
}
// Start task
_addLog('启动任务 ${task.taskId}: ${task.course.name}');
taskFutures.add(_executeConcurrentTask(task));
}
// Wait for all tasks to complete
_addLog('所有任务已启动,等待完成...');
final results = await Future.wait(taskFutures);
// Calculate statistics
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
final result = BatchEvaluationResult(
total: results.length,
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
// Save result
_lastResult = result;
_currentProgress = _totalProgress;
// Save evaluation results to history
await _saveEvaluationResults(result.results);
// Add completion log
if (result.failed > 0) {
_addLog(
'⚠️ 并发评教完成: 成功 ${result.success}/${result.total},失败 ${result.failed}',
);
} else {
_addLog('✅ 并发评教完成: 全部 ${result.total} 门课程评教成功');
}
// Show completion notification
await _notificationService.showCompletionNotification(
success: result.success,
failed: result.failed,
total: result.total,
);
// Reload courses to update evaluation status
_addLog('刷新课程列表...');
await loadCourses();
_addLog('课程列表已更新');
_setState(EvaluationState.completed);
_isConcurrentMode = false;
return result;
} catch (e) {
_errorMessage = '并发评教失败: $e';
_setState(EvaluationState.error);
_isConcurrentMode = false;
// Show error notification
await _notificationService.showErrorNotification(_errorMessage!);
return null;
}
}
/// Execute a single concurrent task
Future<EvaluationResult> _executeConcurrentTask(ConcurrentTask task) async {
try {
// Update task status
_updateTaskStatus(
task.taskId,
TaskStatus.preparing,
'❤ Created By LoveACE Team, 🌧 Powered By Sibuxiangx & Flutter',
);
_addLog('任务 ${task.taskId} [${task.course.name}]: 开始评教');
task.startTime = DateTime.now();
// 1. Prepare evaluation
_updateTaskStatus(task.taskId, TaskStatus.preparing, '访问评价页面');
_addLog('任务 ${task.taskId} [${task.course.name}]: 访问评价页面');
final formData = await _service!.prepareEvaluation(
task.course,
totalCourses: _totalProgress,
);
if (formData == null) {
_updateTaskStatus(
task.taskId,
TaskStatus.failed,
'访问失败',
errorMessage: '无法访问评价页面',
);
_addLog('任务 ${task.taskId} [${task.course.name}]: ❌ 访问失败');
return EvaluationResult(
course: task.course,
success: false,
errorMessage: '无法访问评价页面',
);
}
_updateTaskStatus(task.taskId, TaskStatus.preparing, '解析问卷');
_addLog('任务 ${task.taskId} [${task.course.name}]: 解析问卷');
_updateTaskStatus(task.taskId, TaskStatus.preparing, '生成答案');
_addLog('任务 ${task.taskId} [${task.course.name}]: 生成答案');
// 2. Countdown (140 seconds) - independent for each task
_updateTaskStatus(
task.taskId,
TaskStatus.countdown,
'等待提交',
countdownTotal: 140,
);
_addLog('任务 ${task.taskId} [${task.course.name}]: 开始独立等待 140 秒');
for (int countdown = 140; countdown > 0; countdown--) {
_updateTaskCountdown(task.taskId, countdown, 140);
await Future.delayed(const Duration(seconds: 1));
}
_addLog('任务 ${task.taskId} [${task.course.name}]: 等待完成');
// 3. Submit evaluation
_updateTaskStatus(task.taskId, TaskStatus.submitting, '提交评价');
_addLog('任务 ${task.taskId} [${task.course.name}]: 提交评价');
final result = await _service!.submitEvaluation(task.course, formData);
if (!result.success) {
_updateTaskStatus(
task.taskId,
TaskStatus.failed,
'提交失败',
errorMessage: result.errorMessage,
);
_addLog('任务 ${task.taskId} [${task.course.name}]: ❌ 提交失败');
return result;
}
// 4. Verify evaluation
_updateTaskStatus(task.taskId, TaskStatus.verifying, '验证结果');
_addLog('任务 ${task.taskId} [${task.course.name}]: 验证结果');
final updatedCourses = await _connection!.fetchCourseList();
final updatedCourse = updatedCourses.firstWhere(
(c) => c.id == task.course.id,
orElse: () => task.course,
);
if (!updatedCourse.isEvaluated) {
_updateTaskStatus(
task.taskId,
TaskStatus.failed,
'验证失败',
errorMessage: '评教未生效,服务器未确认',
);
_addLog('任务 ${task.taskId} [${task.course.name}]: ❌ 评教未生效');
return EvaluationResult(
course: task.course,
success: false,
errorMessage: '评教未生效,服务器未确认',
);
}
// Success
_updateTaskStatus(task.taskId, TaskStatus.completed, '完成');
_addLog('任务 ${task.taskId} [${task.course.name}]: ✅ 评教完成');
_currentProgress++;
notifyListeners();
task.endTime = DateTime.now();
return EvaluationResult(course: task.course, success: true);
} catch (e) {
_updateTaskStatus(
task.taskId,
TaskStatus.failed,
'异常',
errorMessage: e.toString(),
);
_addLog('任务 ${task.taskId} [${task.course.name}]: ❌ 异常: $e');
return EvaluationResult(
course: task.course,
success: false,
errorMessage: '评教过程出错: $e',
);
}
}
/// Update task status
void _updateTaskStatus(
int taskId,
TaskStatus status,
String? statusMessage, {
String? errorMessage,
int? countdownTotal,
}) {
final index = _concurrentTasks.indexWhere((t) => t.taskId == taskId);
if (index != -1) {
_concurrentTasks[index] = _concurrentTasks[index].copyWith(
status: status,
statusMessage: statusMessage,
errorMessage: errorMessage,
countdownTotal: countdownTotal,
);
notifyListeners();
}
}
/// Update task countdown
void _updateTaskCountdown(int taskId, int remaining, int total) {
final index = _concurrentTasks.indexWhere((t) => t.taskId == taskId);
if (index != -1) {
_concurrentTasks[index] = _concurrentTasks[index].copyWith(
countdownRemaining: remaining,
countdownTotal: total,
);
notifyListeners();
}
}
}

View File

@@ -0,0 +1,390 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Color scheme options for the app
enum AppColorScheme { blue, green, purple, orange }
/// Provider for managing app theme and appearance settings
///
/// Handles theme mode (light/dark/system), color scheme selection,
/// and persistence of user preferences
///
/// Usage example:
/// ```dart
/// final themeProvider = Provider.of<ThemeProvider>(context);
///
/// // Change theme mode
/// themeProvider.setThemeMode(ThemeMode.dark);
///
/// // Change color scheme
/// themeProvider.setColorScheme(AppColorScheme.green);
///
/// // Get current theme data
/// final lightTheme = themeProvider.lightTheme;
/// final darkTheme = themeProvider.darkTheme;
/// ```
class ThemeProvider extends ChangeNotifier {
static const String _themeModeKey = 'theme_mode';
static const String _colorSchemeKey = 'color_scheme';
ThemeMode _themeMode = ThemeMode.system;
AppColorScheme _colorScheme = AppColorScheme.blue;
ThemeProvider() {
_loadPreferences();
}
/// Get current theme mode
ThemeMode get themeMode => _themeMode;
/// Get current color scheme
AppColorScheme get colorScheme => _colorScheme;
/// Get light theme data
ThemeData get lightTheme => _buildLightTheme();
/// Get dark theme data
ThemeData get darkTheme => _buildDarkTheme();
/// Set theme mode
///
/// [mode] - The theme mode to set (light, dark, or system)
/// Saves preference and notifies listeners
Future<void> setThemeMode(ThemeMode mode) async {
if (_themeMode == mode) return;
_themeMode = mode;
notifyListeners();
await savePreferences();
}
/// Set color scheme
///
/// [scheme] - The color scheme to set
/// Saves preference and notifies listeners
Future<void> setColorScheme(AppColorScheme scheme) async {
if (_colorScheme == scheme) return;
_colorScheme = scheme;
notifyListeners();
await savePreferences();
}
/// Save preferences to local storage
Future<void> savePreferences() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeModeKey, _themeMode.name);
await prefs.setString(_colorSchemeKey, _colorScheme.name);
} catch (e) {
debugPrint('Failed to save theme preferences: $e');
}
}
/// Load preferences from local storage
Future<void> _loadPreferences() async {
try {
final prefs = await SharedPreferences.getInstance();
// Load theme mode
final themeModeStr = prefs.getString(_themeModeKey);
if (themeModeStr != null) {
_themeMode = ThemeMode.values.firstWhere(
(mode) => mode.name == themeModeStr,
orElse: () => ThemeMode.system,
);
}
// Load color scheme
final colorSchemeStr = prefs.getString(_colorSchemeKey);
if (colorSchemeStr != null) {
_colorScheme = AppColorScheme.values.firstWhere(
(scheme) => scheme.name == colorSchemeStr,
orElse: () => AppColorScheme.blue,
);
}
notifyListeners();
} catch (e) {
debugPrint('Failed to load theme preferences: $e');
}
}
/// Build light theme based on current color scheme
ThemeData _buildLightTheme() {
final colorScheme = _getColorScheme(Brightness.light);
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: colorScheme,
// AppBar theme
appBarTheme: AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
// Card theme
cardTheme: const CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
// Input decoration theme
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.error, width: 2),
),
),
// Elevated button theme
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
// Text button theme
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
// Floating action button theme
floatingActionButtonTheme: FloatingActionButtonThemeData(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
// Progress indicator theme
progressIndicatorTheme: ProgressIndicatorThemeData(
color: colorScheme.primary,
),
// Divider theme
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant,
thickness: 1,
),
);
}
/// Build dark theme based on current color scheme
ThemeData _buildDarkTheme() {
final colorScheme = _getColorScheme(Brightness.dark);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: colorScheme,
// AppBar theme
appBarTheme: AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
),
// Card theme
cardTheme: const CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
// Input decoration theme
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.error, width: 2),
),
),
// Elevated button theme
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
// Text button theme
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
// Floating action button theme
floatingActionButtonTheme: FloatingActionButtonThemeData(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
// Progress indicator theme
progressIndicatorTheme: ProgressIndicatorThemeData(
color: colorScheme.primary,
),
// Divider theme
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant,
thickness: 1,
),
);
}
/// Get color scheme based on brightness and selected color
ColorScheme _getColorScheme(Brightness brightness) {
switch (_colorScheme) {
case AppColorScheme.blue:
return _getBlueColorScheme(brightness);
case AppColorScheme.green:
return _getGreenColorScheme(brightness);
case AppColorScheme.purple:
return _getPurpleColorScheme(brightness);
case AppColorScheme.orange:
return _getOrangeColorScheme(brightness);
}
}
/// Blue color scheme
ColorScheme _getBlueColorScheme(Brightness brightness) {
if (brightness == Brightness.light) {
return ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
);
} else {
return ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
);
}
}
/// Green color scheme
ColorScheme _getGreenColorScheme(Brightness brightness) {
if (brightness == Brightness.light) {
return ColorScheme.fromSeed(
seedColor: Colors.green,
brightness: Brightness.light,
);
} else {
return ColorScheme.fromSeed(
seedColor: Colors.green,
brightness: Brightness.dark,
);
}
}
/// Purple color scheme
ColorScheme _getPurpleColorScheme(Brightness brightness) {
if (brightness == Brightness.light) {
return ColorScheme.fromSeed(
seedColor: Colors.purple,
brightness: Brightness.light,
);
} else {
return ColorScheme.fromSeed(
seedColor: Colors.purple,
brightness: Brightness.dark,
);
}
}
/// Orange color scheme
ColorScheme _getOrangeColorScheme(Brightness brightness) {
if (brightness == Brightness.light) {
return ColorScheme.fromSeed(
seedColor: Colors.orange,
brightness: Brightness.light,
);
} else {
return ColorScheme.fromSeed(
seedColor: Colors.orange,
brightness: Brightness.dark,
);
}
}
/// Get color scheme name for display
String getColorSchemeName(AppColorScheme scheme) {
switch (scheme) {
case AppColorScheme.blue:
return '蓝色';
case AppColorScheme.green:
return '绿色';
case AppColorScheme.purple:
return '紫色';
case AppColorScheme.orange:
return '橙色';
}
}
/// Get theme mode name for display
String getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light:
return '浅色模式';
case ThemeMode.dark:
return '深色模式';
case ThemeMode.system:
return '跟随系统';
}
}
}

2
lib/screens/.gitkeep Normal file
View File

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

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../providers/evaluation_provider.dart';
import '../widgets/course_card.dart';
import 'progress_screen.dart';
import 'settings_screen.dart';
/// Home screen displaying course list and evaluation controls
///
/// Shows list of courses that need evaluation
/// Provides batch evaluation functionality
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
// Defer loading until after the first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadCourses();
});
}
Future<void> _loadCourses() async {
if (!mounted) return;
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final evaluationProvider = Provider.of<EvaluationProvider>(
context,
listen: false,
);
print('🔍 _loadCourses called');
print('🔍 authProvider.connection: ${authProvider.connection}');
print('🔍 authProvider.isAuthenticated: ${authProvider.isAuthenticated}');
// Set connection for evaluation provider
if (authProvider.connection != null) {
print('🔍 Setting connection and loading courses...');
evaluationProvider.setConnection(authProvider.connection);
final success = await evaluationProvider.loadCourses();
print('🔍 loadCourses result: $success');
print('🔍 courses count: ${evaluationProvider.courses.length}');
if (!success) {
print('❌ Error: ${evaluationProvider.errorMessage}');
}
} else {
print('❌ No connection available');
}
}
Future<void> _handleRefresh() async {
await _loadCourses();
}
Future<void> _handleBatchEvaluation() async {
final evaluationProvider = Provider.of<EvaluationProvider>(
context,
listen: false,
);
// Check if there are pending courses
if (evaluationProvider.pendingCourses.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('没有待评课程')));
return;
}
// Navigate to progress screen
if (!mounted) return;
Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => const ProgressScreen()));
// Start concurrent batch evaluation
await evaluationProvider.startConcurrentBatchEvaluation();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('课程评教'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _handleRefresh,
tooltip: '刷新',
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const SettingsScreen()),
);
},
tooltip: '设置',
),
],
),
body: Consumer<EvaluationProvider>(
builder: (context, evaluationProvider, child) {
if (evaluationProvider.state == EvaluationState.loading) {
return const Center(child: CircularProgressIndicator());
}
if (evaluationProvider.state == EvaluationState.error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
evaluationProvider.errorMessage ?? '加载失败',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _handleRefresh,
icon: const Icon(Icons.refresh),
label: const Text('重试'),
),
],
),
);
}
final courses = evaluationProvider.courses;
final pendingCount = evaluationProvider.pendingCourses.length;
final evaluatedCount = evaluationProvider.evaluatedCourses.length;
if (courses.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle_outline,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'暂无待评课程',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'下拉刷新以检查新课程',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return Column(
children: [
// Statistics card
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem(
context,
'总计',
courses.length.toString(),
Icons.list_alt,
),
_buildStatItem(
context,
'待评',
pendingCount.toString(),
Icons.pending_actions,
),
_buildStatItem(
context,
'已评',
evaluatedCount.toString(),
Icons.check_circle,
),
],
),
),
// Course list
Expanded(
child: RefreshIndicator(
onRefresh: _handleRefresh,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: courses.length,
itemBuilder: (context, index) {
final course = courses[index];
return CourseCard(course: course);
},
),
),
),
],
);
},
),
floatingActionButton: Consumer<EvaluationProvider>(
builder: (context, evaluationProvider, child) {
final hasPending = evaluationProvider.pendingCourses.isNotEmpty;
final isEvaluating =
evaluationProvider.state == EvaluationState.evaluating;
if (!hasPending || isEvaluating) {
return const SizedBox.shrink();
}
return FloatingActionButton.extended(
onPressed: _handleBatchEvaluation,
icon: const Icon(Icons.play_arrow),
label: const Text('批量评教'),
);
},
),
);
}
Widget _buildStatItem(
BuildContext context,
String label,
String value,
IconData icon,
) {
return Column(
children: [
Icon(icon, color: Theme.of(context).colorScheme.onPrimaryContainer),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
);
}
}

View File

@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import 'home_screen.dart';
/// Login screen for user authentication
///
/// Provides input fields for student ID, EC password, and UAAP password
/// Integrates with AuthProvider for authentication
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _userIdController = TextEditingController();
final _ecPasswordController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscureEcPassword = true;
bool _obscurePassword = true;
@override
void dispose() {
_userIdController.dispose();
_ecPasswordController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) {
return;
}
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final success = await authProvider.login(
userId: _userIdController.text.trim(),
ecPassword: _ecPasswordController.text,
password: _passwordController.text,
);
if (!mounted) return;
if (success) {
// Navigate to home screen
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const HomeScreen()),
);
} else {
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(authProvider.errorMessage ?? '登录失败'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App logo/title
Icon(
Icons.school,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'自动评教系统',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'安徽财经大学',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
// Student ID field
TextFormField(
controller: _userIdController,
decoration: const InputDecoration(
labelText: '学号',
hintText: '请输入学号',
prefixIcon: Icon(Icons.person),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入学号';
}
return null;
},
),
const SizedBox(height: 16),
// EC password field
TextFormField(
controller: _ecPasswordController,
decoration: InputDecoration(
labelText: 'EC密码',
hintText: '请输入EC系统密码',
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscureEcPassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscureEcPassword = !_obscureEcPassword;
});
},
),
),
obscureText: _obscureEcPassword,
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入EC密码';
}
return null;
},
),
const SizedBox(height: 16),
// UAAP password field
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'UAAP密码',
hintText: '请输入UAAP系统密码',
prefixIcon: const Icon(Icons.vpn_key),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
obscureText: _obscurePassword,
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入UAAP密码';
}
return null;
},
),
const SizedBox(height: 32),
// Login button
Consumer<AuthProvider>(
builder: (context, authProvider, child) {
final isLoading = authProvider.state == AuthState.loading;
return ElevatedButton(
onPressed: isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
),
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('登录', style: TextStyle(fontSize: 16)),
);
},
),
const SizedBox(height: 16),
// Help text
Text(
'首次登录需要输入EC和UAAP系统密码\n登录信息将被安全加密存储',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Signature
Column(
children: [
Text(
'❤ Created By LoveACE Team',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'🌧 Powered By Sibuxiangx & Flutter',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
],
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,647 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/evaluation_provider.dart';
/// Progress screen showing real-time evaluation progress
///
/// Displays progress percentage, current course, and statistics
/// Allows canceling the evaluation process
class ProgressScreen extends StatelessWidget {
const ProgressScreen({super.key});
Future<bool> _onWillPop(BuildContext context, EvaluationState state) async {
// 如果已完成或出错,允许直接返回
if (state == EvaluationState.completed || state == EvaluationState.error) {
return true;
}
// 如果正在评教,显示确认对话框
final shouldPop = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('确认中断'),
content: const Text('评教正在进行中,确定要中断吗?\n\n中断后当前进度将丢失。'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('继续评教'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('中断'),
),
],
),
);
return shouldPop ?? false;
}
@override
Widget build(BuildContext context) {
return Consumer<EvaluationProvider>(
builder: (context, evaluationProvider, child) {
final state = evaluationProvider.state;
final progress = evaluationProvider.progress;
final current = evaluationProvider.currentProgress;
final total = evaluationProvider.totalProgress;
final currentCourse = evaluationProvider.currentCourse;
final currentStatus = evaluationProvider.currentStatus;
final lastResult = evaluationProvider.lastResult;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final shouldPop = await _onWillPop(context, state);
if (shouldPop && context.mounted) {
Navigator.of(context).pop();
}
},
child: Scaffold(
appBar: AppBar(
title: const Text('评教进度'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () async {
final shouldPop = await _onWillPop(context, state);
if (shouldPop && context.mounted) {
Navigator.of(context).pop();
}
},
),
),
body: Builder(
builder: (context) {
// Show completion screen
if (state == EvaluationState.completed && lastResult != null) {
return _buildCompletionScreen(context, lastResult);
}
// Show error screen
if (state == EvaluationState.error) {
return _buildErrorScreen(
context,
evaluationProvider.errorMessage ?? '评教失败',
);
}
// Show progress screen
return _buildProgressScreen(
context,
progress,
current,
total,
currentCourse,
currentStatus,
);
},
),
),
);
},
);
}
Widget _buildProgressScreen(
BuildContext context,
double progress,
int current,
int total,
dynamic currentCourse,
String? status,
) {
return Column(
children: [
// Top: Overall progress info
Expanded(
flex: 2,
child: _buildProgressContent(
context,
progress,
current,
total,
currentCourse,
status,
),
),
// Bottom: Task list
Expanded(flex: 3, child: _buildTaskListPanel(context)),
],
);
}
Widget _buildProgressContent(
BuildContext context,
double progress,
int current,
int total,
dynamic currentCourse,
String? status,
) {
return Center(
child: Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Progress percentage
Text(
'${(progress * 100).toInt()}%',
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 16),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LinearProgressIndicator(
value: progress,
minHeight: 24,
backgroundColor: Theme.of(
context,
).colorScheme.onPrimaryContainer.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 16),
// Complete / All
Text(
'$current / $total',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
),
),
);
}
Widget _buildTaskListPanel(BuildContext context) {
return Consumer<EvaluationProvider>(
builder: (context, provider, child) {
final tasks = provider.concurrentTasks;
return Container(
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
Icon(
Icons.list_alt,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'并发任务列表',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Text(
'${tasks.length} 个任务',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
// Task list
Expanded(
child: tasks.isEmpty
? Center(
child: Text(
'暂无任务',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return _buildTaskCard(context, task);
},
),
),
],
),
);
},
);
}
Widget _buildTaskCard(BuildContext context, dynamic task) {
final statusColor = _getTaskStatusColor(context, task.status);
final statusIcon = _getTaskStatusIcon(task.status);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Task header
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: statusColor.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(statusIcon, size: 14, color: statusColor),
const SizedBox(width: 4),
Text(
'任务 ${task.taskId}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
task.course.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
// Teacher info
Row(
children: [
Icon(
Icons.person,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
task.course.teacher,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
// Status and progress
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
task.statusText,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: task.progress,
minHeight: 6,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
statusColor,
),
),
),
],
),
),
const SizedBox(width: 8),
Text(
'${(task.progress * 100).toInt()}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.bold,
),
),
],
),
// Error message if failed
if (task.status.toString().contains('failed') &&
task.errorMessage != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.error_outline,
size: 16,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
task.errorMessage,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onErrorContainer,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
],
),
),
);
}
Color _getTaskStatusColor(BuildContext context, dynamic status) {
final statusStr = status.toString();
if (statusStr.contains('completed')) {
return Colors.green;
} else if (statusStr.contains('failed')) {
return Theme.of(context).colorScheme.error;
} else if (statusStr.contains('countdown')) {
return Theme.of(context).colorScheme.tertiary;
} else if (statusStr.contains('submitting') ||
statusStr.contains('verifying')) {
return Theme.of(context).colorScheme.secondary;
} else if (statusStr.contains('preparing')) {
return Theme.of(context).colorScheme.primary;
} else {
return Theme.of(context).colorScheme.onSurfaceVariant;
}
}
IconData _getTaskStatusIcon(dynamic status) {
final statusStr = status.toString();
if (statusStr.contains('completed')) {
return Icons.check_circle;
} else if (statusStr.contains('failed')) {
return Icons.error;
} else if (statusStr.contains('countdown')) {
return Icons.timer;
} else if (statusStr.contains('submitting')) {
return Icons.upload;
} else if (statusStr.contains('verifying')) {
return Icons.verified;
} else if (statusStr.contains('preparing')) {
return Icons.settings;
} else {
return Icons.pending;
}
}
Widget _buildCompletionScreen(BuildContext context, dynamic result) {
final isAllSuccess = result.failed == 0;
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 40),
// Success/Warning icon
Icon(
isAllSuccess ? Icons.check_circle : Icons.warning,
size: 100,
color: isAllSuccess
? Colors.green
: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 24),
// Title
Text(
isAllSuccess ? '评教完成' : '评教完成(部分失败)',
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 32),
// Statistics
_buildResultCard(
context,
'总计',
result.total.toString(),
Icons.list_alt,
Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 12),
_buildResultCard(
context,
'成功',
result.success.toString(),
Icons.check_circle,
Colors.green,
),
if (result.failed > 0) ...[
const SizedBox(height: 12),
_buildResultCard(
context,
'失败',
result.failed.toString(),
Icons.error,
Theme.of(context).colorScheme.error,
),
],
const SizedBox(height: 12),
_buildResultCard(
context,
'耗时',
'${result.duration.inSeconds}',
Icons.timer,
Theme.of(context).colorScheme.secondary,
),
const SizedBox(height: 48),
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (result.failed > 0)
ElevatedButton.icon(
onPressed: () {
final evaluationProvider = Provider.of<EvaluationProvider>(
context,
listen: false,
);
evaluationProvider.retryFailed();
},
icon: const Icon(Icons.refresh),
label: const Text('重试失败'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
),
if (result.failed > 0) const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.home),
label: const Text('返回首页'),
),
],
),
const SizedBox(height: 40),
],
),
);
}
Widget _buildResultCard(
BuildContext context,
String label,
String value,
IconData icon,
Color color,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(icon, color: color, size: 32),
const SizedBox(width: 16),
Expanded(
child: Text(label, style: Theme.of(context).textTheme.titleMedium),
),
Text(
value,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
Widget _buildErrorScreen(BuildContext context, String errorMessage) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 80),
Icon(
Icons.error_outline,
size: 100,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 24),
Text(
'评教失败',
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
errorMessage,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
ElevatedButton.icon(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.home),
label: const Text('返回首页'),
),
const SizedBox(height: 80),
],
),
);
}
}

View File

@@ -0,0 +1,368 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../providers/theme_provider.dart';
import '../providers/evaluation_provider.dart';
import '../widgets/confirm_dialog.dart';
import 'login_screen.dart';
/// Settings screen for app configuration
///
/// Provides theme customization, logout, and data management
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
Future<void> _handleLogout(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => const ConfirmDialog(
title: '退出登录',
content: '确定要退出登录吗?',
confirmText: '退出',
cancelText: '取消',
),
);
if (confirmed != true || !context.mounted) return;
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final evaluationProvider = Provider.of<EvaluationProvider>(
context,
listen: false,
);
await authProvider.logout();
evaluationProvider.reset();
if (!context.mounted) return;
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => const LoginScreen()),
(route) => false,
);
}
Future<void> _handleClearData(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => const ConfirmDialog(
title: '清除数据',
content: '确定要清除所有本地数据吗?\n这将删除评教历史记录。',
confirmText: '清除',
cancelText: '取消',
),
);
if (confirmed != true || !context.mounted) return;
final evaluationProvider = Provider.of<EvaluationProvider>(
context,
listen: false,
);
await evaluationProvider.clearEvaluationHistory();
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('数据已清除')));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('设置')),
body: ListView(
children: [
// Theme section
_buildSectionHeader(context, '外观设置'),
_buildThemeModeSelector(context),
_buildColorSchemeSelector(context),
const Divider(height: 32),
// Account section
_buildSectionHeader(context, '账号管理'),
_buildAccountInfo(context),
_buildLogoutTile(context),
const Divider(height: 32),
// Data section
_buildSectionHeader(context, '数据管理'),
_buildClearDataTile(context),
const Divider(height: 32),
// About section
_buildSectionHeader(context, '关于'),
_buildAboutTile(context),
_buildFontLicenseTile(context),
],
),
);
}
Widget _buildSectionHeader(BuildContext context, String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _buildThemeModeSelector(BuildContext context) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return ListTile(
leading: const Icon(Icons.brightness_6),
title: const Text('主题模式'),
subtitle: Text(
themeProvider.getThemeModeName(themeProvider.themeMode),
),
onTap: () {
showDialog(
context: context,
builder: (context) => _ThemeModeDialog(
currentMode: themeProvider.themeMode,
onModeSelected: (mode) {
themeProvider.setThemeMode(mode);
Navigator.of(context).pop();
},
),
);
},
);
},
);
}
Widget _buildColorSchemeSelector(BuildContext context) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return ListTile(
leading: const Icon(Icons.palette),
title: const Text('颜色方案'),
subtitle: Text(
themeProvider.getColorSchemeName(themeProvider.colorScheme),
),
onTap: () {
showDialog(
context: context,
builder: (context) => _ColorSchemeDialog(
currentScheme: themeProvider.colorScheme,
onSchemeSelected: (scheme) {
themeProvider.setColorScheme(scheme);
Navigator.of(context).pop();
},
),
);
},
);
},
);
}
Widget _buildAccountInfo(BuildContext context) {
return Consumer<AuthProvider>(
builder: (context, authProvider, child) {
final credentials = authProvider.credentials;
return ListTile(
leading: const Icon(Icons.account_circle),
title: const Text('当前账号'),
subtitle: Text(credentials?.userId ?? '未登录'),
);
},
);
}
Widget _buildLogoutTile(BuildContext context) {
return ListTile(
leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
title: Text(
'退出登录',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
onTap: () => _handleLogout(context),
);
}
Widget _buildClearDataTile(BuildContext context) {
return ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('清除本地数据'),
subtitle: const Text('删除评教历史记录'),
onTap: () => _handleClearData(context),
);
}
Widget _buildAboutTile(BuildContext context) {
return ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('关于应用'),
subtitle: const Text('版本 1.0.0'),
onTap: () {
showAboutDialog(
context: context,
applicationName: '自动评教系统',
applicationVersion: '1.0.0',
applicationIcon: Icon(
Icons.school,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
children: [
const Text('AUFE自动评教工具'),
const SizedBox(height: 8),
const Text('帮助学生快速完成课程评教任务'),
],
);
},
);
}
Widget _buildFontLicenseTile(BuildContext context) {
return ListTile(
leading: const Icon(Icons.font_download),
title: const Text('字体许可'),
subtitle: const Text('MiSans 字体使用说明'),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('MiSans 字体知识产权许可协议'),
content: const SingleChildScrollView(
child: Text(
'本应用在 Windows 平台使用 MiSans 字体。\n\n'
'根据小米科技有限责任公司的授权MiSans 字体可免费用于个人和商业用途。\n\n'
'使用条件:\n'
'• 应特别注明使用了 MiSans 字体\n'
'• 不得对字体进行改编或二次开发\n'
'• 不得单独分发或售卖字体文件\n'
'• 可自由分发使用该字体创作的作品\n\n'
'本应用遵守以上使用条款。',
style: TextStyle(fontSize: 14),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
],
),
);
},
);
}
}
class _ThemeModeDialog extends StatelessWidget {
final ThemeMode currentMode;
final Function(ThemeMode) onModeSelected;
const _ThemeModeDialog({
required this.currentMode,
required this.onModeSelected,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('选择主题模式'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<ThemeMode>(
title: const Text('浅色模式'),
value: ThemeMode.light,
groupValue: currentMode,
onChanged: (mode) {
if (mode != null) onModeSelected(mode);
},
),
RadioListTile<ThemeMode>(
title: const Text('深色模式'),
value: ThemeMode.dark,
groupValue: currentMode,
onChanged: (mode) {
if (mode != null) onModeSelected(mode);
},
),
RadioListTile<ThemeMode>(
title: const Text('跟随系统'),
value: ThemeMode.system,
groupValue: currentMode,
onChanged: (mode) {
if (mode != null) onModeSelected(mode);
},
),
],
),
);
}
}
class _ColorSchemeDialog extends StatelessWidget {
final AppColorScheme currentScheme;
final Function(AppColorScheme) onSchemeSelected;
const _ColorSchemeDialog({
required this.currentScheme,
required this.onSchemeSelected,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('选择颜色方案'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildColorOption(context, AppColorScheme.blue, '蓝色', Colors.blue),
_buildColorOption(context, AppColorScheme.green, '绿色', Colors.green),
_buildColorOption(
context,
AppColorScheme.purple,
'紫色',
Colors.purple,
),
_buildColorOption(
context,
AppColorScheme.orange,
'橙色',
Colors.orange,
),
],
),
);
}
Widget _buildColorOption(
BuildContext context,
AppColorScheme scheme,
String name,
Color color,
) {
return RadioListTile<AppColorScheme>(
title: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 12),
Text(name),
],
),
value: scheme,
groupValue: currentScheme,
onChanged: (scheme) {
if (scheme != null) onSchemeSelected(scheme);
},
);
}
}

2
lib/services/.gitkeep Normal file
View File

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

View File

@@ -0,0 +1,275 @@
import 'package:flutter/foundation.dart';
import '../providers/auth_provider.dart';
import '../services/notification_service.dart';
import '../services/storage_service.dart';
import '../utils/session_manager.dart';
/// Service for handling app initialization tasks
///
/// Coordinates initialization of various services and providers:
/// - Theme preferences loading
/// - Notification service setup
/// - Session restoration
/// - Cache management
///
/// Usage example:
/// ```dart
/// final initService = AppInitializationService(
/// authProvider: authProvider,
/// themeProvider: themeProvider,
/// notificationService: notificationService,
/// );
///
/// final result = await initService.initialize();
/// if (result.success) {
/// // App initialized successfully
/// }
/// ```
class AppInitializationService {
final AuthProvider _authProvider;
final NotificationService _notificationService;
final StorageService _storageService;
late final SessionManager _sessionManager;
AppInitializationService({
required AuthProvider authProvider,
required NotificationService notificationService,
StorageService? storageService,
}) : _authProvider = authProvider,
_notificationService = notificationService,
_storageService = storageService ?? StorageService() {
_sessionManager = SessionManager(authProvider: _authProvider);
}
/// Initialize the application
///
/// Performs all necessary initialization tasks in the correct order
/// Returns InitializationResult with status and details
Future<InitializationResult> initialize() async {
final startTime = DateTime.now();
final steps = <InitializationStep>[];
try {
// Step 1: Initialize notification service
steps.add(await _initializeNotifications());
// Step 2: Load theme preferences
steps.add(await _loadThemePreferences());
// Step 3: Check and manage cache
steps.add(await _manageCacheVersion());
// Step 4: Attempt session restoration
steps.add(await _restoreSession());
final duration = DateTime.now().difference(startTime);
final allSuccessful = steps.every((step) => step.success);
return InitializationResult(
success: allSuccessful,
steps: steps,
duration: duration,
sessionRestored: steps
.firstWhere(
(s) => s.name == 'Session Restoration',
orElse: () => InitializationStep(
name: 'Session Restoration',
success: false,
),
)
.success,
);
} catch (e) {
debugPrint('Error during app initialization: $e');
final duration = DateTime.now().difference(startTime);
return InitializationResult(
success: false,
steps: steps,
duration: duration,
error: e.toString(),
);
}
}
/// Initialize notification service
Future<InitializationStep> _initializeNotifications() async {
try {
await _notificationService.initialize();
return InitializationStep(
name: 'Notification Service',
success: true,
message: 'Notification service initialized',
);
} catch (e) {
debugPrint('Failed to initialize notifications: $e');
return InitializationStep(
name: 'Notification Service',
success: false,
message: 'Failed to initialize notifications: $e',
);
}
}
/// Load theme preferences
Future<InitializationStep> _loadThemePreferences() async {
try {
// Theme preferences are loaded in ThemeProvider constructor
// This step just confirms it's ready
return InitializationStep(
name: 'Theme Preferences',
success: true,
message: 'Theme preferences loaded',
);
} catch (e) {
debugPrint('Failed to load theme preferences: $e');
return InitializationStep(
name: 'Theme Preferences',
success: false,
message: 'Failed to load theme preferences: $e',
);
}
}
/// Manage cache version
Future<InitializationStep> _manageCacheVersion() async {
try {
const expectedCacheVersion = 1; // Update this when data structure changes
final cleared = await _storageService.clearCacheIfVersionMismatch(
expectedCacheVersion,
);
if (cleared) {
return InitializationStep(
name: 'Cache Management',
success: true,
message: 'Cache cleared due to version mismatch',
);
} else {
return InitializationStep(
name: 'Cache Management',
success: true,
message: 'Cache version is current',
);
}
} catch (e) {
debugPrint('Failed to manage cache: $e');
return InitializationStep(
name: 'Cache Management',
success: false,
message: 'Failed to manage cache: $e',
);
}
}
/// Attempt to restore previous session
Future<InitializationStep> _restoreSession() async {
try {
// Check if session can be restored
final canRestore = await _sessionManager.canRestoreSession();
if (!canRestore) {
return InitializationStep(
name: 'Session Restoration',
success: false,
message: 'No saved session to restore',
);
}
// Attempt restoration
final result = await _sessionManager.restoreSession();
if (result.success) {
return InitializationStep(
name: 'Session Restoration',
success: true,
message: 'Session restored successfully',
);
} else {
// Session restoration failed, but this is not a critical error
// User will just need to login again
return InitializationStep(
name: 'Session Restoration',
success: false,
message: result.message,
reason: result.reason,
);
}
} catch (e) {
debugPrint('Error during session restoration: $e');
return InitializationStep(
name: 'Session Restoration',
success: false,
message: 'Session restoration error: $e',
);
}
}
/// Get session manager instance
SessionManager get sessionManager => _sessionManager;
/// Get storage service instance
StorageService get storageService => _storageService;
}
/// Result of app initialization
class InitializationResult {
final bool success;
final List<InitializationStep> steps;
final Duration duration;
final bool sessionRestored;
final String? error;
InitializationResult({
required this.success,
required this.steps,
required this.duration,
this.sessionRestored = false,
this.error,
});
/// Get failed steps
List<InitializationStep> get failedSteps =>
steps.where((step) => !step.success).toList();
/// Get successful steps
List<InitializationStep> get successfulSteps =>
steps.where((step) => step.success).toList();
/// Check if initialization is complete (even with some non-critical failures)
bool get isComplete => steps.isNotEmpty;
@override
String toString() {
return 'InitializationResult('
'success: $success, '
'sessionRestored: $sessionRestored, '
'steps: ${steps.length}, '
'duration: ${duration.inMilliseconds}ms'
')';
}
}
/// Individual initialization step
class InitializationStep {
final String name;
final bool success;
final String? message;
final SessionRestoreFailureReason? reason;
InitializationStep({
required this.name,
required this.success,
this.message,
this.reason,
});
@override
String toString() {
return 'InitializationStep('
'name: $name, '
'success: $success, '
'message: $message'
')';
}
}

View File

@@ -0,0 +1,539 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:crypto/crypto.dart';
import 'package:pointycastle/export.dart';
import '../models/login_status.dart';
import '../models/course.dart';
import 'http_client.dart';
import '../utils/retry_handler.dart';
/// AUFE教务系统连接类
class AUFEConnection {
final String userId;
final String ecPassword;
final String password;
late HTTPClient _client;
String? _twfId;
String? _token;
bool _ecLogged = false;
bool _uaapLogged = false;
DateTime _lastCheck = DateTime.now();
// 配置常量
static const String serverUrl = 'https://vpn.aufe.edu.cn';
static const String uaapLoginUrl =
'http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3A%2F%2Fjwcxk2.aufe.edu.cn%2Fj_spring_cas_security_check';
static const String uaapCheckUrl =
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/';
static const String ecCheckUrl =
'http://txzx-aufe-edu-cn-s.vpn2.aufe.edu.cn:8118/dzzy/list.htm';
AUFEConnection({
required this.userId,
required this.ecPassword,
required this.password,
});
/// 初始化HTTP客户端
void startClient() {
_client = HTTPClient(baseUrl: serverUrl, timeout: 30000);
}
/// EC系统登录RSA加密
Future<ECLoginStatus> ecLogin() async {
try {
return await RetryHandler.retry(
operation: () async => await _performEcLogin(),
retryIf: RetryHandler.shouldRetryOnError,
maxAttempts: 3,
);
} catch (e) {
return ECLoginStatus(failUnknownError: true);
}
}
Future<ECLoginStatus> _performEcLogin() async {
try {
// 1. 获取认证参数
final response = await _client.get('/por/login_auth.csp?apiversion=1');
final responseText = response.data.toString();
// 2. 提取TwfID
final twfIdMatch = RegExp(
r'<TwfID>(.*?)</TwfID>',
).firstMatch(responseText);
if (twfIdMatch == null) {
return ECLoginStatus(failNotFoundTwfid: true);
}
_twfId = twfIdMatch.group(1);
// 3. 提取RSA密钥
final rsaKeyMatch = RegExp(
r'<RSA_ENCRYPT_KEY>(.*?)</RSA_ENCRYPT_KEY>',
).firstMatch(responseText);
if (rsaKeyMatch == null) {
return ECLoginStatus(failNotFoundRsaKey: true);
}
final rsaKey = rsaKeyMatch.group(1)!;
// 4. 提取RSA指数
final rsaExpMatch = RegExp(
r'<RSA_ENCRYPT_EXP>(.*?)</RSA_ENCRYPT_EXP>',
).firstMatch(responseText);
if (rsaExpMatch == null) {
return ECLoginStatus(failNotFoundRsaExp: true);
}
final rsaExp = rsaExpMatch.group(1)!;
// 5. 提取CSRF代码
final csrfMatch = RegExp(
r'<CSRF_RAND_CODE>(.*?)</CSRF_RAND_CODE>',
).firstMatch(responseText);
if (csrfMatch == null) {
return ECLoginStatus(failNotFoundCsrfCode: true);
}
final csrfCode = csrfMatch.group(1)!;
// 6. RSA加密密码
final passwordToEncrypt = '${ecPassword}_$csrfCode';
final encryptedPassword = _rsaEncrypt(passwordToEncrypt, rsaKey, rsaExp);
// 7. 执行登录
final loginResponse = await _client.post(
'/por/login_psw.csp?anti_replay=1&encrypt=1&type=cs',
data: {
'svpn_rand_code': '',
'mitm': '',
'svpn_req_randcode': csrfCode,
'svpn_name': userId,
'svpn_password': encryptedPassword,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
headers: {'Cookie': 'TWFID=$_twfId'},
),
);
final loginResponseText = loginResponse.data.toString();
// 8. 检查登录结果
if (loginResponseText.contains('<Result>1</Result>')) {
_client.setCookie('TWFID', _twfId!);
_ecLogged = true;
return ECLoginStatus(success: true);
} else if (loginResponseText.contains('Invalid username or password!')) {
return ECLoginStatus(failInvalidCredentials: true);
} else if (loginResponseText.contains('[CDATA[maybe attacked]]') ||
loginResponseText.contains('CAPTCHA required')) {
return ECLoginStatus(failMaybeAttacked: true);
} else {
return ECLoginStatus(failUnknownError: true);
}
} on DioException catch (e) {
return ECLoginStatus(failNetworkError: true);
} catch (e) {
return ECLoginStatus(failUnknownError: true);
}
}
/// RSA加密
String _rsaEncrypt(String plaintext, String modulusHex, String exponentStr) {
// 解析模数和指数
final modulus = BigInt.parse(modulusHex, radix: 16);
final exponent = BigInt.parse(exponentStr);
// 创建RSA公钥
final publicKey = RSAPublicKey(modulus, exponent);
// 创建加密器
final encryptor = PKCS1Encoding(RSAEngine());
encryptor.init(true, PublicKeyParameter<RSAPublicKey>(publicKey));
// 加密
final plainBytes = utf8.encode(plaintext);
final encrypted = encryptor.process(Uint8List.fromList(plainBytes));
// 转换为十六进制字符串
return encrypted.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// UAAP系统登录DES加密
Future<UAAPLoginStatus> uaapLogin() async {
try {
return await RetryHandler.retry(
operation: () async => await _performUaapLogin(),
retryIf: RetryHandler.shouldRetryOnError,
maxAttempts: 3,
);
} catch (e) {
return UAAPLoginStatus(failUnknownError: true);
}
}
Future<UAAPLoginStatus> _performUaapLogin() async {
try {
// 1. 获取登录页面
final response = await _client.get(uaapLoginUrl);
final responseText = response.data.toString();
// 2. 提取lt参数
final ltMatch = RegExp(
r'name="lt" value="(.*?)"',
).firstMatch(responseText);
if (ltMatch == null) {
return UAAPLoginStatus(failNotFoundLt: true);
}
final ltValue = ltMatch.group(1)!;
// 3. 提取execution参数
final executionMatch = RegExp(
r'name="execution" value="(.*?)"',
).firstMatch(responseText);
if (executionMatch == null) {
return UAAPLoginStatus(failNotFoundExecution: true);
}
final executionValue = executionMatch.group(1)!;
// 4. DES加密密码
final encryptedPassword = _desEncrypt(password, ltValue);
// 5. 提交登录表单
final loginResponse = await _client.post(
uaapLoginUrl,
data: {
'username': userId,
'password': encryptedPassword,
'lt': ltValue,
'execution': executionValue,
'_eventId': 'submit',
'submit': 'LOGIN',
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
followRedirects: false,
validateStatus: (status) => status! < 500,
),
);
// 6. 检查登录结果并访问重定向URL以建立session
if (loginResponse.statusCode == 302) {
final location = loginResponse.headers['location']?.first ?? '';
print('🔐 UAAP redirect location: $location');
if (location.contains('ticket=')) {
// 访问重定向URL以完成CAS认证并建立session
print('🔐 Following redirect to establish session...');
final ticketResponse = await _client.get(
location,
options: Options(
followRedirects: true,
validateStatus: (status) => status! < 500,
),
);
print('🔐 Ticket response status: ${ticketResponse.statusCode}');
_uaapLogged = true;
return UAAPLoginStatus(success: true);
}
}
final loginResponseText = loginResponse.data.toString();
if (loginResponseText.contains('Invalid username or password')) {
return UAAPLoginStatus(failInvalidCredentials: true);
}
return UAAPLoginStatus(failUnknownError: true);
} on DioException catch (e) {
return UAAPLoginStatus(failNetworkError: true);
} catch (e) {
return UAAPLoginStatus(failUnknownError: true);
}
}
/// DES加密使用TripleDES ECB模式
String _desEncrypt(String plaintext, String key) {
// 处理密钥 - 取前8字节
var keyBytes = utf8.encode(key);
if (keyBytes.length > 8) {
keyBytes = keyBytes.sublist(0, 8);
} else if (keyBytes.length < 8) {
// 不足8字节用0填充
keyBytes = Uint8List(8)..setRange(0, keyBytes.length, keyBytes);
}
// 创建DES密钥TripleDES使用相同的8字节密钥重复3次
final desKey = KeyParameter(
Uint8List(24)
..setRange(0, 8, keyBytes)
..setRange(8, 16, keyBytes)
..setRange(16, 24, keyBytes),
);
// 创建加密器
final cipher = PaddedBlockCipherImpl(PKCS7Padding(), DESedeEngine());
cipher.init(true, PaddedBlockCipherParameters(desKey, null));
// 加密
final plainBytes = utf8.encode(plaintext);
final encrypted = cipher.process(Uint8List.fromList(plainBytes));
// Base64编码
return base64.encode(encrypted);
}
/// 检查EC登录状态
Future<ECCheckStatus> checkEcLoginStatus() async {
if (!_ecLogged) {
return ECCheckStatus(loggedIn: false);
}
try {
final response = await _client.get(ecCheckUrl);
if (response.statusCode == 200) {
return ECCheckStatus(loggedIn: true);
} else {
return ECCheckStatus(loggedIn: false);
}
} on DioException catch (e) {
return ECCheckStatus(failNetworkError: true);
} catch (e) {
return ECCheckStatus(failUnknownError: true);
}
}
/// 检查UAAP登录状态
Future<ECCheckStatus> checkUaapLoginStatus() async {
return ECCheckStatus(loggedIn: _uaapLogged);
}
/// 健康检查
Future<bool> healthCheck() async {
final delta = DateTime.now().difference(_lastCheck);
// 5分钟未检查则视为不健康
if (delta.inSeconds > 300) {
return false;
}
// 检查UAAP登录状态
final uaapStatus = await checkUaapLoginStatus();
if (!uaapStatus.isLoggedIn) {
return false;
}
// 检查EC登录状态
final ecStatus = await checkEcLoginStatus();
if (!ecStatus.isLoggedIn) {
return false;
}
return true;
}
/// 更新健康检查时间戳
void healthCheckpoint() {
_lastCheck = DateTime.now();
}
/// 关闭连接
Future<void> close() async {
_client.close();
}
/// 获取HTTP客户端
HTTPClient get client {
healthCheckpoint();
return _client;
}
/// 获取CSRF Token
Future<String?> getToken() async {
try {
final response = await _client.get(
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/evaluation/index',
);
if (response.statusCode != 200) {
return null;
}
final html = response.data.toString();
final tokenMatch = RegExp(
r'id="tokenValue"[^>]*value="([^"]*)"',
).firstMatch(html);
if (tokenMatch != null) {
_token = tokenMatch.group(1);
return _token;
}
return null;
} catch (e) {
return null;
}
}
/// 获取待评课程列表
Future<List<Course>> fetchCourseList() async {
try {
final response = await _client.post(
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/search?sf_request_type=ajax',
data: {'optType': '1', 'pagesize': '50'},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
if (response.statusCode != 200) {
return [];
}
final data = response.data;
if (data is Map<String, dynamic> && data['data'] is List) {
final courseList = (data['data'] as List)
.map((item) => _parseCourse(item))
.where((course) => course != null)
.cast<Course>()
.toList();
return courseList;
}
return [];
} catch (e) {
return [];
}
}
/// 解析课程数据
Course? _parseCourse(dynamic item) {
try {
if (item is! Map<String, dynamic>) return null;
final id = item['id'] as Map<String, dynamic>?;
final questionnaire = item['questionnaire'] as Map<String, dynamic>?;
return Course(
id: id?['evaluatedPeople']?.toString() ?? '',
name: item['evaluationContent']?.toString() ?? '',
teacher: item['evaluatedPeople']?.toString() ?? '',
evaluatedPeople: item['evaluatedPeople']?.toString() ?? '',
evaluatedPeopleNumber: id?['evaluatedPeople']?.toString() ?? '',
coureSequenceNumber: id?['coureSequenceNumber']?.toString() ?? '',
evaluationContentNumber:
id?['evaluationContentNumber']?.toString() ?? '',
questionnaireCode:
questionnaire?['questionnaireNumber']?.toString() ?? '',
questionnaireName:
questionnaire?['questionnaireName']?.toString() ?? '',
isEvaluated: item['isEvaluated']?.toString() == '',
);
} catch (e) {
return null;
}
}
/// 访问评价页面并返回HTML内容
Future<String?> accessEvaluationPage(
Course course, {
int? totalCourses,
}) async {
try {
print('📝 Accessing evaluation page for: ${course.name}');
if (_token == null) {
print('📝 Getting token first...');
await getToken();
print('📝 Token: $_token');
}
// count is the total number of courses in the course list
final count = totalCourses?.toString() ?? '28';
print('📝 Using count: $count (total courses: $totalCourses)');
print('📝 Posting to evaluation page...');
final response = await _client.post(
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/evaluationPage',
data: {
'count': count,
'evaluatedPeople': course.evaluatedPeople,
'evaluatedPeopleNumber': course.evaluatedPeopleNumber,
'questionnaireCode': course.questionnaireCode,
'questionnaireName': course.questionnaireName,
'coureSequenceNumber': course.coureSequenceNumber,
'evaluationContentNumber': course.evaluationContentNumber,
'evaluationContentContent': '',
'tokenValue': _token ?? '',
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
print('📝 Access evaluation page status: ${response.statusCode}');
if (response.statusCode == 200) {
final html = response.data.toString();
print('📝 Got HTML content, length: ${html.length}');
return html;
}
return null;
} catch (e, stackTrace) {
print('❌ accessEvaluationPage error: $e');
print('❌ Stack trace: $stackTrace');
return null;
}
}
/// 提交评价表单
Future<EvaluationResponse> submitEvaluation(
Map<String, String> formData,
) async {
try {
print('📤 Submitting evaluation with form data:');
print('📤 Form data entries: ${formData.length}');
formData.forEach((key, value) {
print(
' $key = ${value.length > 50 ? value.substring(0, 50) + "..." : value}',
);
});
final response = await _client.post(
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/assessment?sf_request_type=ajax',
data: formData,
options: Options(contentType: Headers.formUrlEncodedContentType),
);
print('📤 Submit response status: ${response.statusCode}');
print('📤 Submit response data: ${response.data}');
if (response.statusCode != 200) {
return EvaluationResponse(
result: 'error',
msg: '网络请求失败 (${response.statusCode})',
);
}
final data = response.data;
if (data is Map<String, dynamic>) {
return EvaluationResponse(
result: data['result']?.toString() ?? 'error',
msg: data['msg']?.toString() ?? '未知错误',
);
}
return EvaluationResponse(result: 'error', msg: '响应格式错误');
} catch (e) {
print('❌ Submit evaluation error: $e');
return EvaluationResponse(result: 'error', msg: '请求异常: $e');
}
}
}
/// 评价响应
class EvaluationResponse {
final String result;
final String msg;
EvaluationResponse({required this.result, required this.msg});
bool get isSuccess => result == 'success';
}

View File

@@ -0,0 +1,431 @@
import 'dart:math';
import '../models/course.dart';
import '../models/questionnaire.dart';
import '../models/evaluation_result.dart';
import 'aufe_connection.dart';
import 'questionnaire_parser.dart';
import '../utils/text_generator.dart';
/// Service for handling course evaluation operations
/// Manages the evaluation process including form data building,
/// option selection, and text generation
class EvaluationService {
final AUFEConnection _connection;
final QuestionnaireParser _parser;
final TextGenerator _textGenerator;
final Random _random = Random();
EvaluationService({
required AUFEConnection connection,
required QuestionnaireParser parser,
required TextGenerator textGenerator,
}) : _connection = connection,
_parser = parser,
_textGenerator = textGenerator;
/// Prepare evaluation for a course (access page, parse questionnaire, generate answers)
///
/// [course] - The course to evaluate
/// [totalCourses] - Total number of courses (for count parameter)
/// Returns form data map if successful, null otherwise
Future<Map<String, String>?> prepareEvaluation(
Course course, {
int? totalCourses,
}) async {
try {
// 1. Access evaluation page to get questionnaire HTML
final html = await _connection.accessEvaluationPage(
course,
totalCourses: totalCourses,
);
if (html == null) {
return null;
}
// 2. Parse questionnaire
final questionnaire = await _parser.parseAsync(html);
// 3. Build form data
final formData = _buildFormData(
questionnaire,
course: course,
totalCourses: totalCourses,
);
return formData;
} catch (e, stackTrace) {
print('❌ prepareEvaluation error: $e');
print('❌ Stack trace: $stackTrace');
return null;
}
}
/// Submit evaluation with prepared form data
///
/// [course] - The course being evaluated
/// [formData] - Prepared form data from prepareEvaluation
/// Returns [EvaluationResult] with success status and error message if failed
Future<EvaluationResult> submitEvaluation(
Course course,
Map<String, String> formData,
) async {
try {
final submitResponse = await _connection.submitEvaluation(formData);
if (submitResponse.isSuccess) {
return EvaluationResult(course: course, success: true);
} else {
return EvaluationResult(
course: course,
success: false,
errorMessage: submitResponse.msg,
);
}
} catch (e, stackTrace) {
print('❌ submitEvaluation error: $e');
print('❌ Stack trace: $stackTrace');
return EvaluationResult(
course: course,
success: false,
errorMessage: '提交评价出错: $e',
);
}
}
/// Evaluate a single course (legacy method, combines prepare and submit with countdown)
///
/// [course] - The course to evaluate
/// [totalCourses] - Total number of courses (for count parameter)
/// [onStatusChange] - Optional callback to report status changes during evaluation
/// [onCountdown] - Optional callback to report countdown progress (seconds remaining, total seconds)
/// Returns [EvaluationResult] with success status and error message if failed
Future<EvaluationResult> evaluateCourse(
Course course, {
int? totalCourses,
Function(String status)? onStatusChange,
Function(int remaining, int total)? onCountdown,
}) async {
try {
// 1. Prepare evaluation
onStatusChange?.call('正在访问评价页面...');
final formData = await prepareEvaluation(
course,
totalCourses: totalCourses,
);
if (formData == null) {
return EvaluationResult(
course: course,
success: false,
errorMessage: '无法访问评价页面',
);
}
onStatusChange?.call('正在解析问卷结构...');
onStatusChange?.call('正在生成答案...');
// 2. Wait 140 seconds before submission (server anti-bot requirement)
onStatusChange?.call('等待提交中...');
const totalSeconds = 140;
for (int i = totalSeconds; i > 0; i--) {
onCountdown?.call(i, totalSeconds);
// Also report to status for logging
final progress = (totalSeconds - i) / totalSeconds;
final barLength = 20;
final filledLength = (progress * barLength).round();
final bar = '' * filledLength + '' * (barLength - filledLength);
final percent = (progress * 100).toInt();
onStatusChange?.call('等待提交 [$bar] $percent% (${i}s)');
await Future.delayed(const Duration(seconds: 1));
}
// 3. Submit evaluation
onStatusChange?.call('正在提交评价...');
final result = await submitEvaluation(course, formData);
if (result.success) {
onStatusChange?.call('提交成功');
} else {
onStatusChange?.call('提交失败: ${result.errorMessage}');
}
return result;
} catch (e, stackTrace) {
print('❌ evaluateCourse error: $e');
print('❌ Stack trace: $stackTrace');
onStatusChange?.call('评教失败');
return EvaluationResult(
course: course,
success: false,
errorMessage: '评教过程出错: $e',
);
}
}
/// Build form data from questionnaire
///
/// Automatically selects best options for radio questions
/// and generates appropriate text for text questions
Map<String, String> _buildFormData(
Questionnaire questionnaire, {
required Course course,
int? totalCourses,
}) {
final formData = <String, String>{};
// Add required metadata fields (matching the correct request format)
// Use Course data as primary source, fallback to questionnaire parsed data
formData['optType'] = 'submit';
formData['tokenValue'] = questionnaire.tokenValue;
formData['questionnaireCode'] = course.questionnaireCode.isNotEmpty
? course.questionnaireCode
: questionnaire.questionnaireCode;
formData['evaluationContent'] = course.evaluationContentNumber.isNotEmpty
? course.evaluationContentNumber
: questionnaire.evaluationContent;
formData['evaluatedPeopleNumber'] = course.evaluatedPeopleNumber.isNotEmpty
? course.evaluatedPeopleNumber
: questionnaire.evaluatedPeopleNumber;
formData['count'] = totalCourses?.toString() ?? '';
// Process radio questions - select best option for each
for (final question in questionnaire.radioQuestions) {
final selectedOption = _selectBestOption(question.options);
formData[question.key] = selectedOption.value;
}
// Process text questions - generate appropriate text
for (final question in questionnaire.textQuestions) {
final generatedText = _generateTextAnswer(question);
formData[question.key] = generatedText;
}
print('📝 Form data keys: ${formData.keys.join(", ")}');
print('📝 Metadata fields:');
print(' optType = ${formData['optType']}');
print(' tokenValue = ${formData['tokenValue']}');
print(' questionnaireCode = ${formData['questionnaireCode']}');
print(' evaluationContent = ${formData['evaluationContent']}');
print(' evaluatedPeopleNumber = ${formData['evaluatedPeopleNumber']}');
print(' count = ${formData['count']}');
print(
'📝 Question answers (${questionnaire.radioQuestions.length} radio + ${questionnaire.textQuestions.length} text):',
);
formData.forEach((key, value) {
if (key.startsWith('0000') || key == 'zgpj') {
print(' $key = $value');
}
});
return formData;
}
/// Select the best option from a list of radio options
///
/// Strategy: Prefer weight 1.0 options (80% probability)
/// Otherwise select second highest weight option (20% probability)
///
/// [options] - List of available radio options
/// Returns the selected [RadioOption]
RadioOption _selectBestOption(List<RadioOption> options) {
if (options.isEmpty) {
throw ArgumentError('Options list cannot be empty');
}
// Sort options by weight in descending order
final sortedOptions = List<RadioOption>.from(options)
..sort((a, b) => b.weight.compareTo(a.weight));
// Find options with weight 1.0
final weight1Options = sortedOptions
.where((opt) => opt.weight == 1.0)
.toList();
// If there are weight 1.0 options
if (weight1Options.isNotEmpty) {
// 80% chance to select weight 1.0 option
if (_random.nextDouble() < 0.8) {
// If multiple weight 1.0 options, randomly select one
return weight1Options[_random.nextInt(weight1Options.length)];
}
}
// 20% chance or no weight 1.0 options: select second highest weight
if (sortedOptions.length > 1) {
// Find second highest weight
final highestWeight = sortedOptions[0].weight;
final secondHighestOptions = sortedOptions
.where((opt) => opt.weight < highestWeight)
.toList();
if (secondHighestOptions.isNotEmpty) {
// Get all options with second highest weight
final secondHighestWeight = secondHighestOptions[0].weight;
final secondHighestGroup = secondHighestOptions
.where((opt) => opt.weight == secondHighestWeight)
.toList();
return secondHighestGroup[_random.nextInt(secondHighestGroup.length)];
}
}
// Fallback: return highest weight option
return sortedOptions[0];
}
/// Generate text answer for a text question
///
/// Uses TextGenerator to create appropriate text based on question type
/// Validates the generated text meets all requirements
///
/// [question] - The text question to answer
/// Returns generated text string
String _generateTextAnswer(TextQuestion question) {
// Generate text based on question type
String text = _textGenerator.generate(question.type);
// Validate the generated text
// If validation fails, try again (up to 3 attempts)
int attempts = 0;
while (!_textGenerator.validate(text) && attempts < 3) {
text = _textGenerator.generate(question.type);
attempts++;
}
return text;
}
/// Batch evaluate all pending courses
///
/// [onProgress] - Callback function to report progress with detailed status
/// Returns [BatchEvaluationResult] with statistics and individual results
Future<BatchEvaluationResult> batchEvaluate({
required Function(int current, int total, Course course, String status)
onProgress,
}) async {
final startTime = DateTime.now();
final results = <EvaluationResult>[];
try {
// 1. Fetch all pending courses
final courses = await _connection.fetchCourseList();
// Filter out already evaluated courses
final pendingCourses = courses.where((c) => !c.isEvaluated).toList();
final total = pendingCourses.length;
if (total == 0) {
return BatchEvaluationResult(
total: 0,
success: 0,
failed: 0,
results: [],
duration: DateTime.now().difference(startTime),
);
}
// 2. Evaluate each course
for (int i = 0; i < pendingCourses.length; i++) {
final course = pendingCourses[i];
// Evaluate the course with status updates
final result = await evaluateCourse(
course,
totalCourses: total,
onStatusChange: (status) {
// Report progress with current course and status
// Use i (not i+1) to show completed count, current course is still in progress
onProgress(i, total, course, status);
},
);
results.add(result);
// Check if evaluation failed
if (!result.success) {
// Report failure
onProgress(i + 1, total, course, '评教失败,任务中断');
// Stop batch evaluation on first error
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
return BatchEvaluationResult(
total: i + 1, // Only count evaluated courses
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
}
// Verify evaluation by checking course list
onProgress(i + 1, total, course, '验证评教结果');
final updatedCourses = await _connection.fetchCourseList();
final updatedCourse = updatedCourses.firstWhere(
(c) => c.id == course.id,
orElse: () => course,
);
if (!updatedCourse.isEvaluated) {
// Evaluation not confirmed, treat as failure
results[results.length - 1] = EvaluationResult(
course: course,
success: false,
errorMessage: '评教未生效,服务器未确认',
);
onProgress(i + 1, total, course, '评教未生效,任务中断');
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
return BatchEvaluationResult(
total: i + 1,
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
}
// Report successful completion
onProgress(i + 1, total, course, '评教完成');
// Small delay between evaluations to avoid overwhelming the server
if (i < pendingCourses.length - 1) {
await Future.delayed(const Duration(milliseconds: 500));
}
}
// 3. Calculate statistics
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
return BatchEvaluationResult(
total: total,
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
} catch (e) {
// If batch evaluation fails completely, return partial results
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
return BatchEvaluationResult(
total: results.length,
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
}
}
}

View File

@@ -0,0 +1,143 @@
import 'package:dio/dio.dart';
/// HTTP客户端封装类提供统一的网络请求接口
class HTTPClient {
late Dio _dio;
final Map<String, String> _cookies = {};
HTTPClient({String? baseUrl, int timeout = 30000}) {
_dio = Dio(
BaseOptions(
baseUrl: baseUrl ?? '',
connectTimeout: Duration(milliseconds: timeout),
receiveTimeout: Duration(milliseconds: timeout),
sendTimeout: Duration(milliseconds: timeout),
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
},
validateStatus: (status) => status != null && status < 500,
),
);
// 添加拦截器用于Cookie管理和日志
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
// 添加Cookie到请求头
if (_cookies.isNotEmpty) {
final cookieStr = _cookies.entries
.map((e) => '${e.key}=${e.value}')
.join('; ');
options.headers['Cookie'] = cookieStr;
}
// 打印请求信息
print('🌐 ${options.method} ${options.uri}');
return handler.next(options);
},
onResponse: (response, handler) {
// 从响应中提取Cookie
final setCookie = response.headers['set-cookie'];
if (setCookie != null) {
for (var cookie in setCookie) {
_parseCookie(cookie);
}
}
final statusCode = response.statusCode ?? 0;
// 如果状态码 >= 400打印详细错误信息
if (statusCode >= 400) {
print(
'❌ Response Error: $statusCode ${response.requestOptions.uri}',
);
print('❌ Response Headers: ${response.headers}');
print('❌ Response Data: ${response.data}');
} else {
// 正常响应只打印状态码
print('$statusCode ${response.requestOptions.uri}');
}
return handler.next(response);
},
onError: (error, handler) {
print('❌ HTTP Error: ${error.message}');
print('❌ Error type: ${error.type}');
print(
'❌ Request: ${error.requestOptions.method} ${error.requestOptions.uri}',
);
if (error.response != null) {
print('❌ Status code: ${error.response?.statusCode}');
print('❌ Response Headers: ${error.response?.headers}');
print('❌ Response Data: ${error.response?.data}');
}
return handler.next(error);
},
),
);
}
/// 解析Cookie字符串并存储
void _parseCookie(String cookieStr) {
final parts = cookieStr.split(';')[0].split('=');
if (parts.length == 2) {
_cookies[parts[0].trim()] = parts[1].trim();
}
}
/// GET请求
Future<Response> get(
String path, {
Map<String, dynamic>? params,
Options? options,
}) async {
return await _dio.get(path, queryParameters: params, options: options);
}
/// POST请求
Future<Response> post(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
return await _dio.post(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
}
/// 设置Cookie
void setCookie(String name, String value) {
_cookies[name] = value;
}
/// 获取Cookie
String? getCookie(String name) {
return _cookies[name];
}
/// 获取所有Cookie
Map<String, String> getAllCookies() {
return Map.from(_cookies);
}
/// 清除所有Cookie
void clearCookies() {
_cookies.clear();
}
/// 关闭客户端
void close() {
_dio.close();
}
/// 获取Dio实例用于高级操作
Dio get dio => _dio;
}

View File

@@ -0,0 +1,203 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
/// Service for managing local notifications
/// Handles batch evaluation progress notifications and completion alerts
///
/// Usage example:
/// ```dart
/// final notificationService = NotificationService();
/// await notificationService.initialize();
///
/// // Set up tap callback
/// notificationService.onNotificationTapped = (payload) {
/// // Handle navigation based on payload
/// if (payload == 'batch_complete') {
/// // Navigate to results screen
/// }
/// };
///
/// // Show batch start notification
/// await notificationService.showBatchStartNotification(10);
///
/// // Update progress
/// await notificationService.updateProgressNotification(
/// current: 5,
/// total: 10,
/// courseName: '高等数学',
/// );
///
/// // Show completion
/// await notificationService.showCompletionNotification(
/// success: 9,
/// failed: 1,
/// total: 10,
/// );
/// ```
class NotificationService {
static const String _channelId = 'evaluation_progress';
static const String _channelName = '评教进度';
static const String _channelDescription = '显示批量评教的实时进度';
static const int _batchNotificationId = 1000;
static const int _progressNotificationId = 1001;
static const int _completionNotificationId = 1002;
static const int _errorNotificationId = 1003;
final FlutterLocalNotificationsPlugin _notifications;
bool _isInitialized = false;
// Callback for when notification is tapped
Function(String?)? onNotificationTapped;
NotificationService() : _notifications = FlutterLocalNotificationsPlugin();
/// Initialize the notification service
///
/// Configures notification channels for Android and iOS
/// Sets up notification icons and default settings
///
/// Returns true if initialization succeeds, false otherwise
Future<bool> initialize() async {
if (_isInitialized) {
return true;
}
try {
// Android initialization settings
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
// iOS initialization settings
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
// Combined initialization settings
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
// Initialize the plugin
final initialized = await _notifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationTapped,
onDidReceiveBackgroundNotificationResponse: _onNotificationTapped,
);
if (initialized == true) {
// Create notification channel for Android
await _createNotificationChannel();
_isInitialized = true;
return true;
}
return false;
} catch (e) {
debugPrint('Failed to initialize notification service: $e');
return false;
}
}
/// Create Android notification channel
///
/// Sets up a high-priority channel for evaluation progress notifications
Future<void> _createNotificationChannel() async {
const androidChannel = AndroidNotificationChannel(
_channelId,
_channelName,
description: _channelDescription,
importance: Importance.high,
enableVibration: true,
playSound: true,
);
await _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(androidChannel);
}
/// Handle notification tap events
///
/// Called when user taps on a notification
/// Triggers the callback to allow navigation to specific screens
void _onNotificationTapped(NotificationResponse response) {
debugPrint('Notification tapped: ${response.payload}');
// Trigger the callback if set
if (onNotificationTapped != null) {
onNotificationTapped!(response.payload);
}
}
/// Check if the service is initialized
bool get isInitialized => _isInitialized;
/// Show notification when batch evaluation starts
///
/// [totalCourses] - Total number of courses to evaluate
Future<void> showBatchStartNotification(int totalCourses) async {
// Notification service disabled
return;
}
/// Update progress notification with current status
///
/// Shows a progress bar and current course being evaluated
///
/// [current] - Number of courses completed
/// [total] - Total number of courses
/// [courseName] - Name of the current course being evaluated
Future<void> updateProgressNotification({
required int current,
required int total,
required String courseName,
}) async {
// Notification service disabled
return;
}
/// Show completion notification with final statistics
///
/// [success] - Number of successfully evaluated courses
/// [failed] - Number of failed evaluations
/// [total] - Total number of courses
Future<void> showCompletionNotification({
required int success,
required int failed,
required int total,
}) async {
// Notification service disabled
return;
}
/// Show error notification
///
/// [message] - Error message to display
Future<void> showErrorNotification(String message) async {
// Notification service disabled
return;
}
/// Cancel all active notifications
///
/// Clears all notifications from the notification tray
Future<void> cancelAll() async {
if (!_isInitialized) {
debugPrint('Notification service not initialized');
return;
}
try {
await _notifications.cancelAll();
} catch (e) {
debugPrint('Failed to cancel notifications: $e');
}
}
}

View File

@@ -0,0 +1,412 @@
import 'package:flutter/foundation.dart';
import 'package:html/parser.dart' as html_parser;
import 'package:html/dom.dart';
import '../models/questionnaire.dart';
/// Parser for HTML questionnaire documents
/// Dynamically extracts questionnaire structure including radio questions,
/// text questions, and metadata
class QuestionnaireParser {
// Cache for parsed questionnaires
static final Map<String, Questionnaire> _cache = {};
/// Parse HTML in isolate for better performance
///
/// [htmlContent] - The HTML content of the questionnaire page
/// [useCache] - Whether to use cached results (default: true)
/// Returns a [Questionnaire] object containing all parsed data
Future<Questionnaire> parseAsync(
String htmlContent, {
bool useCache = true,
}) async {
// Generate cache key from content hash
final cacheKey = htmlContent.hashCode.toString();
// Check cache first
if (useCache && _cache.containsKey(cacheKey)) {
return _cache[cacheKey]!;
}
// Parse in isolate to avoid blocking UI thread
final questionnaire = await compute(_parseInIsolate, htmlContent);
// Store in cache
if (useCache) {
_cache[cacheKey] = questionnaire;
// Limit cache size to prevent memory issues
if (_cache.length > 50) {
// Remove oldest entries (simple FIFO)
final keysToRemove = _cache.keys.take(_cache.length - 50).toList();
for (var key in keysToRemove) {
_cache.remove(key);
}
}
}
return questionnaire;
}
/// Clear the parser cache
static void clearCache() {
_cache.clear();
}
/// Static method for isolate parsing
static Questionnaire _parseInIsolate(String htmlContent) {
final parser = QuestionnaireParser();
return parser.parse(htmlContent);
}
/// Parse HTML document and extract questionnaire structure
///
/// [htmlContent] - The HTML content of the questionnaire page
/// Returns a [Questionnaire] object containing all parsed data
Questionnaire parse(String htmlContent) {
final document = html_parser.parse(htmlContent);
// Extract metadata first
final metadata = _extractMetadata(document);
// Extract radio questions (single-choice questions)
final radioQuestions = _extractRadioQuestions(document);
// Extract text questions (open-ended questions)
final textQuestions = _extractTextQuestions(document);
return Questionnaire(
metadata: metadata,
radioQuestions: radioQuestions,
textQuestions: textQuestions,
tokenValue: metadata.tokenValue,
questionnaireCode: metadata.questionnaireCode,
evaluationContent: metadata.evaluationContent,
evaluatedPeopleNumber: metadata.evaluatedPeopleNumber,
);
}
/// Extract questionnaire metadata from HTML document
///
/// Extracts:
/// - Title (questionnaire title)
/// - Evaluated person (teacher name)
/// - Evaluation content
/// - Token value (CSRF token)
/// - Questionnaire code
/// - Evaluated people number
QuestionnaireMetadata _extractMetadata(Document document) {
String title = '';
String evaluatedPerson = '';
String evaluationContent = '';
String tokenValue = '';
String questionnaireCode = '';
String evaluatedPeopleNumber = '';
// Extract title - usually in a specific div or h1/h2 tag
final titleElement =
document.querySelector('div.title') ??
document.querySelector('h1') ??
document.querySelector('h2');
if (titleElement != null) {
title = titleElement.text.trim();
}
// Extract token value from hidden input
final tokenInput = document.querySelector('input[name="tokenValue"]');
if (tokenInput != null) {
tokenValue = tokenInput.attributes['value'] ?? '';
}
// Extract questionnaire code from hidden input
final codeInput = document.querySelector('input[name="wjdm"]');
if (codeInput != null) {
questionnaireCode = codeInput.attributes['value'] ?? '';
}
// Extract evaluated people number from hidden input
final peopleNumberInput = document.querySelector('input[name="bprdm"]');
if (peopleNumberInput != null) {
evaluatedPeopleNumber = peopleNumberInput.attributes['value'] ?? '';
}
// Extract evaluation content from hidden input
final contentInput = document.querySelector('input[name="pgnr"]');
if (contentInput != null) {
evaluationContent = contentInput.attributes['value'] ?? '';
}
// Try to extract evaluated person name from table or specific elements
// Look for teacher name in common patterns
final teacherElements = document.querySelectorAll('td');
for (var element in teacherElements) {
final text = element.text.trim();
if (text.contains('被评人') || text.contains('教师')) {
// Get the next sibling or adjacent cell
final nextSibling = element.nextElementSibling;
if (nextSibling != null) {
evaluatedPerson = nextSibling.text.trim();
break;
}
}
}
return QuestionnaireMetadata(
title: title,
evaluatedPerson: evaluatedPerson,
evaluationContent: evaluationContent,
tokenValue: tokenValue,
questionnaireCode: questionnaireCode,
evaluatedPeopleNumber: evaluatedPeopleNumber,
);
}
/// Extract all radio questions from the document
///
/// Parses all input[type="radio"] elements and groups them by name attribute
/// Extracts score and weight from value attribute (format: "score_weight")
List<RadioQuestion> _extractRadioQuestions(Document document) {
final Map<String, RadioQuestion> questionsMap = {};
// Find all radio input elements
final radioInputs = document.querySelectorAll('input[type="radio"]');
for (var input in radioInputs) {
final name = input.attributes['name'];
final value = input.attributes['value'];
if (name == null || value == null || name.isEmpty || value.isEmpty) {
continue;
}
// Parse value format "score_weight" (e.g., "5_1" means 5 points with 100% weight)
final parts = value.split('_');
double score = 0.0;
double weight = 0.0;
if (parts.length >= 2) {
score = double.tryParse(parts[0]) ?? 0.0;
weight = double.tryParse(parts[1]) ?? 0.0;
}
// Extract option label - look for adjacent label or text
String label = '';
// Try to find label element associated with this input
final inputId = input.attributes['id'];
if (inputId != null && inputId.isNotEmpty) {
final labelElement = document.querySelector('label[for="$inputId"]');
if (labelElement != null) {
label = labelElement.text.trim();
}
}
// If no label found, look for parent label
if (label.isEmpty) {
var parent = input.parent;
while (parent != null && parent.localName != 'label') {
parent = parent.parent;
}
if (parent != null && parent.localName == 'label') {
label = parent.text.trim();
}
}
// If still no label, look for adjacent text in the same td/cell
if (label.isEmpty) {
var cell = input.parent;
while (cell != null && cell.localName != 'td') {
cell = cell.parent;
}
if (cell != null) {
label = cell.text.trim();
}
}
// Create RadioOption
final option = RadioOption(
label: label,
value: value,
score: score,
weight: weight,
);
// Extract question text and category
if (!questionsMap.containsKey(name)) {
String questionText = '';
String category = '';
// Find the question text - usually in a td with rowspan or previous row
var row = input.parent;
while (row != null && row.localName != 'tr') {
row = row.parent;
}
if (row != null) {
// Look for td with rowspan (category indicator)
final categoryCell = row.querySelector('td[rowspan]');
if (categoryCell != null) {
category = categoryCell.text.trim();
}
// Look for question text in the first td or a specific class
final cells = row.querySelectorAll('td');
for (var cell in cells) {
final text = cell.text.trim();
// Skip cells that only contain radio buttons or are too short
if (text.isNotEmpty &&
!text.contains('input') &&
text.length > 5 &&
cell.querySelector('input[type="radio"]') == null) {
questionText = text;
break;
}
}
// If question text not found in current row, check previous rows
if (questionText.isEmpty) {
var prevRow = row.previousElementSibling;
while (prevRow != null) {
final prevCells = prevRow.querySelectorAll('td');
for (var cell in prevCells) {
final text = cell.text.trim();
if (text.isNotEmpty && text.length > 5) {
questionText = text;
break;
}
}
if (questionText.isNotEmpty) break;
prevRow = prevRow.previousElementSibling;
}
}
}
questionsMap[name] = RadioQuestion(
key: name,
questionText: questionText,
options: [option],
category: category,
);
} else {
// Add option to existing question
final existingQuestion = questionsMap[name]!;
questionsMap[name] = RadioQuestion(
key: existingQuestion.key,
questionText: existingQuestion.questionText,
options: [...existingQuestion.options, option],
category: existingQuestion.category,
);
}
}
return questionsMap.values.toList();
}
/// Extract all text questions from the document
///
/// Parses all textarea elements and identifies question types
/// based on surrounding text content
List<TextQuestion> _extractTextQuestions(Document document) {
final List<TextQuestion> textQuestions = [];
// Find all textarea elements
final textareas = document.querySelectorAll('textarea');
for (var textarea in textareas) {
final name = textarea.attributes['name'];
if (name == null || name.isEmpty) {
continue;
}
// Extract question text from adjacent elements
String questionText = '';
// Look for question text in the same row or previous elements
var cell = textarea.parent;
while (cell != null && cell.localName != 'td') {
cell = cell.parent;
}
if (cell != null) {
// Check previous sibling cells for question text
var prevCell = cell.previousElementSibling;
if (prevCell != null) {
questionText = prevCell.text.trim();
}
// If not found, look in the same cell before the textarea
if (questionText.isEmpty) {
final cellText = cell.text.trim();
if (cellText.isNotEmpty) {
questionText = cellText;
}
}
// If still not found, look in previous row
if (questionText.isEmpty) {
var row = cell.parent;
if (row != null && row.localName == 'tr') {
var prevRow = row.previousElementSibling;
if (prevRow != null) {
final prevCells = prevRow.querySelectorAll('td');
for (var prevCell in prevCells) {
final text = prevCell.text.trim();
if (text.isNotEmpty && text.length > 3) {
questionText = text;
break;
}
}
}
}
}
}
// Analyze question type based on text content and name
final questionType = _analyzeQuestionType(questionText, name);
// Determine if required - zgpj is typically required
final isRequired = name == 'zgpj' || name.contains('zgpj');
textQuestions.add(
TextQuestion(
key: name,
questionText: questionText,
type: questionType,
isRequired: isRequired,
),
);
}
return textQuestions;
}
/// Analyze question type based on question text and field name
///
/// Uses keyword matching to identify:
/// - Inspiration questions (contains "启发")
/// - Suggestion questions (contains "建议" or "意见")
/// - Overall evaluation (name is "zgpj")
/// - General questions (default)
QuestionType _analyzeQuestionType(String questionText, String fieldName) {
// Check field name first
if (fieldName == 'zgpj' || fieldName.contains('zgpj')) {
return QuestionType.overall;
}
// Check question text for keywords
final lowerText = questionText.toLowerCase();
if (lowerText.contains('启发') || lowerText.contains('启示')) {
return QuestionType.inspiration;
}
if (lowerText.contains('建议') ||
lowerText.contains('意见') ||
lowerText.contains('改进')) {
return QuestionType.suggestion;
}
// Default to general type
return QuestionType.general;
}
}

View File

@@ -0,0 +1,325 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/evaluation_history.dart';
/// Service for managing local storage using SharedPreferences
///
/// Handles storage of evaluation history, cache management,
/// and other persistent data (excluding secure credentials)
///
/// Usage example:
/// ```dart
/// final storageService = StorageService();
///
/// // Save evaluation history
/// await storageService.saveEvaluationHistory(history);
///
/// // Load evaluation history
/// final histories = await storageService.loadEvaluationHistory();
///
/// // Clear all data
/// await storageService.clearAllData();
/// ```
class StorageService {
static const String _evaluationHistoryKey = 'evaluation_history';
static const String _lastSyncTimeKey = 'last_sync_time';
static const String _cacheVersionKey = 'cache_version';
static const int _maxHistoryItems = 100; // Maximum history items to keep
/// Save evaluation history to local storage
///
/// Appends new history item to existing list
/// Automatically trims list if it exceeds max items
///
/// [history] - The evaluation history to save
Future<void> saveEvaluationHistory(EvaluationHistory history) async {
try {
final prefs = await SharedPreferences.getInstance();
// Load existing history
final histories = await loadEvaluationHistory();
// Add new history at the beginning
histories.insert(0, history);
// Trim if exceeds max items
if (histories.length > _maxHistoryItems) {
histories.removeRange(_maxHistoryItems, histories.length);
}
// Convert to JSON and save
final jsonList = histories.map((h) => h.toJson()).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(_evaluationHistoryKey, jsonString);
} catch (e) {
throw StorageException('Failed to save evaluation history: $e');
}
}
/// Save multiple evaluation histories at once
///
/// Useful for batch operations
///
/// [histories] - List of evaluation histories to save
Future<void> saveEvaluationHistories(
List<EvaluationHistory> histories,
) async {
try {
final prefs = await SharedPreferences.getInstance();
// Load existing history
final existingHistories = await loadEvaluationHistory();
// Merge new histories at the beginning
final mergedHistories = [...histories, ...existingHistories];
// Trim if exceeds max items
final trimmedHistories = mergedHistories.length > _maxHistoryItems
? mergedHistories.sublist(0, _maxHistoryItems)
: mergedHistories;
// Convert to JSON and save
final jsonList = trimmedHistories.map((h) => h.toJson()).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(_evaluationHistoryKey, jsonString);
} catch (e) {
throw StorageException('Failed to save evaluation histories: $e');
}
}
/// Load evaluation history from local storage
///
/// Returns list of evaluation histories sorted by timestamp (newest first)
/// Returns empty list if no history exists
Future<List<EvaluationHistory>> loadEvaluationHistory() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_evaluationHistoryKey);
if (jsonString == null || jsonString.isEmpty) {
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
final histories = jsonList
.map(
(json) => EvaluationHistory.fromJson(json as Map<String, dynamic>),
)
.toList();
// Sort by timestamp (newest first)
histories.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return histories;
} catch (e) {
throw StorageException('Failed to load evaluation history: $e');
}
}
/// Get evaluation history for a specific course
///
/// [courseId] - The course ID to filter by
/// Returns list of histories for the specified course
Future<List<EvaluationHistory>> getHistoryByCourse(String courseId) async {
try {
final allHistories = await loadEvaluationHistory();
return allHistories.where((h) => h.course.id == courseId).toList();
} catch (e) {
throw StorageException('Failed to get history by course: $e');
}
}
/// Get successful evaluation count
///
/// Returns the total number of successful evaluations
Future<int> getSuccessfulEvaluationCount() async {
try {
final histories = await loadEvaluationHistory();
return histories.where((h) => h.success).length;
} catch (e) {
throw StorageException('Failed to get successful evaluation count: $e');
}
}
/// Get failed evaluation count
///
/// Returns the total number of failed evaluations
Future<int> getFailedEvaluationCount() async {
try {
final histories = await loadEvaluationHistory();
return histories.where((h) => !h.success).length;
} catch (e) {
throw StorageException('Failed to get failed evaluation count: $e');
}
}
/// Clear evaluation history
///
/// Removes all stored evaluation history
Future<void> clearEvaluationHistory() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_evaluationHistoryKey);
} catch (e) {
throw StorageException('Failed to clear evaluation history: $e');
}
}
/// Save last sync time
///
/// Records when the last data synchronization occurred
///
/// [time] - The timestamp to save
Future<void> saveLastSyncTime(DateTime time) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastSyncTimeKey, time.toIso8601String());
} catch (e) {
throw StorageException('Failed to save last sync time: $e');
}
}
/// Load last sync time
///
/// Returns the timestamp of the last synchronization
/// Returns null if no sync has occurred
Future<DateTime?> loadLastSyncTime() async {
try {
final prefs = await SharedPreferences.getInstance();
final timeString = prefs.getString(_lastSyncTimeKey);
if (timeString == null) {
return null;
}
return DateTime.parse(timeString);
} catch (e) {
throw StorageException('Failed to load last sync time: $e');
}
}
/// Get cache version
///
/// Returns the current cache version number
/// Used for cache invalidation when data structure changes
Future<int> getCacheVersion() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_cacheVersionKey) ?? 1;
} catch (e) {
throw StorageException('Failed to get cache version: $e');
}
}
/// Set cache version
///
/// Updates the cache version number
///
/// [version] - The new version number
Future<void> setCacheVersion(int version) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_cacheVersionKey, version);
} catch (e) {
throw StorageException('Failed to set cache version: $e');
}
}
/// Clear cache if version mismatch
///
/// Compares current cache version with expected version
/// Clears cache if they don't match
///
/// [expectedVersion] - The expected cache version
/// Returns true if cache was cleared, false otherwise
Future<bool> clearCacheIfVersionMismatch(int expectedVersion) async {
try {
final currentVersion = await getCacheVersion();
if (currentVersion != expectedVersion) {
await clearEvaluationHistory();
await setCacheVersion(expectedVersion);
return true;
}
return false;
} catch (e) {
throw StorageException('Failed to check cache version: $e');
}
}
/// Clear all local data
///
/// Removes all data stored by this service
/// Does NOT clear secure storage (credentials)
/// Does NOT clear theme preferences
Future<void> clearAllData() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_evaluationHistoryKey);
await prefs.remove(_lastSyncTimeKey);
// Keep cache version to maintain compatibility
} catch (e) {
throw StorageException('Failed to clear all data: $e');
}
}
/// Get storage statistics
///
/// Returns information about stored data
Future<StorageStats> getStorageStats() async {
try {
final histories = await loadEvaluationHistory();
final lastSync = await loadLastSyncTime();
final cacheVersion = await getCacheVersion();
return StorageStats(
totalHistoryItems: histories.length,
successfulEvaluations: histories.where((h) => h.success).length,
failedEvaluations: histories.where((h) => !h.success).length,
lastSyncTime: lastSync,
cacheVersion: cacheVersion,
);
} catch (e) {
throw StorageException('Failed to get storage stats: $e');
}
}
}
/// Storage statistics data class
class StorageStats {
final int totalHistoryItems;
final int successfulEvaluations;
final int failedEvaluations;
final DateTime? lastSyncTime;
final int cacheVersion;
StorageStats({
required this.totalHistoryItems,
required this.successfulEvaluations,
required this.failedEvaluations,
this.lastSyncTime,
required this.cacheVersion,
});
@override
String toString() {
return 'StorageStats('
'total: $totalHistoryItems, '
'success: $successfulEvaluations, '
'failed: $failedEvaluations, '
'lastSync: $lastSyncTime, '
'cacheVersion: $cacheVersion'
')';
}
}
/// Exception thrown when storage operations fail
class StorageException implements Exception {
final String message;
StorageException(this.message);
@override
String toString() => 'StorageException: $message';
}

2
lib/utils/.gitkeep Normal file
View File

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

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

@@ -0,0 +1,239 @@
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';
/// Application-wide logger with configurable levels and output
class AppLogger {
static final AppLogger _instance = AppLogger._internal();
factory AppLogger() => _instance;
late final Logger _logger;
bool _isInitialized = false;
AppLogger._internal();
/// Initialize the logger with appropriate settings
void initialize({Level? level, LogOutput? output, LogFilter? filter}) {
if (_isInitialized) return;
_logger = Logger(
level: level ?? _getDefaultLevel(),
filter: filter ?? ProductionFilter(),
printer: _createPrinter(),
output: output ?? _createOutput(),
);
_isInitialized = true;
}
/// Get default log level based on build mode
Level _getDefaultLevel() {
if (kDebugMode) {
return Level.debug;
} else if (kProfileMode) {
return Level.info;
} else {
return Level.warning;
}
}
/// Create appropriate printer based on build mode
LogPrinter _createPrinter() {
if (kDebugMode) {
// Detailed printer for development
return PrettyPrinter(
methodCount: 2,
errorMethodCount: 8,
lineLength: 120,
colors: true,
printEmojis: true,
dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
);
} else {
// Simple printer for production
return SimplePrinter(colors: false);
}
}
/// Create appropriate output based on build mode
LogOutput _createOutput() {
if (kReleaseMode) {
// In production, you might want to send logs to a file or remote service
return ConsoleOutput();
} else {
return ConsoleOutput();
}
}
/// Ensure logger is initialized
void _ensureInitialized() {
if (!_isInitialized) {
initialize();
}
}
/// Log a trace message (most verbose)
void trace(String message, {Object? error, StackTrace? stackTrace}) {
_ensureInitialized();
_logger.t(message, error: error, stackTrace: stackTrace);
}
/// Log a debug message
void debug(String message, {Object? error, StackTrace? stackTrace}) {
_ensureInitialized();
_logger.d(message, error: error, stackTrace: stackTrace);
}
/// Log an info message
void info(String message, {Object? error, StackTrace? stackTrace}) {
_ensureInitialized();
_logger.i(message, error: error, stackTrace: stackTrace);
}
/// Log a warning message
void warning(String message, {Object? error, StackTrace? stackTrace}) {
_ensureInitialized();
_logger.w(message, error: error, stackTrace: stackTrace);
}
/// Log an error message
void error(String message, {Object? error, StackTrace? stackTrace}) {
_ensureInitialized();
_logger.e(message, error: error, stackTrace: stackTrace);
}
/// Log a fatal error message
void fatal(String message, {Object? error, StackTrace? stackTrace}) {
_ensureInitialized();
_logger.f(message, error: error, stackTrace: stackTrace);
}
/// Log network request
void logRequest(
String method,
String url, {
Map<String, dynamic>? headers,
dynamic body,
}) {
if (!kReleaseMode) {
_ensureInitialized();
final buffer = StringBuffer();
buffer.writeln('$method $url');
if (headers != null && headers.isNotEmpty) {
buffer.writeln('Headers: ${_sanitizeHeaders(headers)}');
}
if (body != null) {
buffer.writeln('Body: ${_sanitizeBody(body)}');
}
_logger.d(buffer.toString());
}
}
/// Log network response
void logResponse(
String method,
String url,
int statusCode, {
dynamic body,
Duration? duration,
}) {
if (!kReleaseMode) {
_ensureInitialized();
final buffer = StringBuffer();
buffer.write('$method $url [$statusCode]');
if (duration != null) {
buffer.write(' (${duration.inMilliseconds}ms)');
}
buffer.writeln();
if (body != null) {
buffer.writeln('Body: ${_sanitizeBody(body)}');
}
if (statusCode >= 200 && statusCode < 300) {
_logger.d(buffer.toString());
} else if (statusCode >= 400) {
_logger.e(buffer.toString());
} else {
_logger.w(buffer.toString());
}
}
}
/// Sanitize headers to remove sensitive information
Map<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
final sanitized = Map<String, dynamic>.from(headers);
// List of sensitive header keys to redact
const sensitiveKeys = [
'authorization',
'cookie',
'set-cookie',
'x-api-key',
'x-auth-token',
];
for (final key in sensitiveKeys) {
if (sanitized.containsKey(key.toLowerCase())) {
sanitized[key] = '***REDACTED***';
}
}
return sanitized;
}
/// Sanitize body to remove sensitive information
dynamic _sanitizeBody(dynamic body) {
if (body is Map) {
final sanitized = Map<String, dynamic>.from(body);
// List of sensitive field names to redact
const sensitiveFields = [
'password',
'pwd',
'passwd',
'token',
'secret',
'apiKey',
'api_key',
];
for (final field in sensitiveFields) {
if (sanitized.containsKey(field)) {
sanitized[field] = '***REDACTED***';
}
}
return sanitized;
}
// For string bodies, check if it contains sensitive patterns
if (body is String) {
if (body.length > 1000) {
return '${body.substring(0, 1000)}... (truncated)';
}
// Redact potential passwords in query strings or form data
return body.replaceAllMapped(
RegExp(
r'(password|pwd|passwd|token|secret)=[^&\s]+',
caseSensitive: false,
),
(match) => '${match.group(1)}=***REDACTED***',
);
}
return body;
}
/// Close the logger and release resources
void close() {
if (_isInitialized) {
_logger.close();
_isInitialized = false;
}
}
}

View File

@@ -0,0 +1,174 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'app_logger.dart';
import 'exceptions.dart';
/// Global error handler for the application
class ErrorHandler {
static final ErrorHandler _instance = ErrorHandler._internal();
factory ErrorHandler() => _instance;
ErrorHandler._internal();
final AppLogger _logger = AppLogger();
bool _isInitialized = false;
/// Initialize global error handling
void initialize() {
if (_isInitialized) return;
// Capture Flutter framework errors
FlutterError.onError = (FlutterErrorDetails details) {
_handleFlutterError(details);
};
// Capture errors in platform-specific code
PlatformDispatcher.instance.onError = (error, stack) {
_handlePlatformError(error, stack);
return true;
};
_isInitialized = true;
_logger.info('Global error handler initialized');
}
/// Run the app with error zone guarding
static Future<void> runAppWithErrorHandling(
Future<void> Function() appRunner,
) async {
final errorHandler = ErrorHandler();
errorHandler.initialize();
// Run app in a guarded zone to catch async errors
await runZonedGuarded(
() async {
await appRunner();
},
(error, stackTrace) {
errorHandler._handleZoneError(error, stackTrace);
},
);
}
/// Handle Flutter framework errors
void _handleFlutterError(FlutterErrorDetails details) {
// Log the error
_logger.error(
'Flutter Error',
error: details.exception,
stackTrace: details.stack,
);
// In debug mode, show the red error screen
if (kDebugMode) {
FlutterError.presentError(details);
}
// Optionally report to crash reporting service
_reportError(details.exception, details.stack);
}
/// Handle platform-specific errors
bool _handlePlatformError(Object error, StackTrace stackTrace) {
_logger.error('Platform Error', error: error, stackTrace: stackTrace);
_reportError(error, stackTrace);
return true;
}
/// Handle errors from runZonedGuarded
void _handleZoneError(Object error, StackTrace stackTrace) {
_logger.error('Async Error', error: error, stackTrace: stackTrace);
_reportError(error, stackTrace);
}
/// Report error to external service (optional)
void _reportError(Object error, StackTrace? stackTrace) {
// In production, you could send errors to services like:
// - Firebase Crashlytics
// - Sentry
// - Custom error reporting endpoint
if (kReleaseMode) {
// TODO: Implement error reporting to external service
// Example: FirebaseCrashlytics.instance.recordError(error, stackTrace);
}
}
/// Handle and display user-friendly error messages
static String getUserFriendlyMessage(Object error) {
if (error is AppException) {
return error.message;
} else if (error is NetworkException) {
return '网络连接失败,请检查网络设置';
} else if (error is AuthenticationException) {
return '登录失败,请检查账号密码';
} else if (error is ParseException) {
return '数据解析失败,请稍后重试';
} else if (error is ValidationException) {
return '输入验证失败:${error.message}';
} else if (error is TimeoutException) {
return '请求超时,请稍后重试';
} else if (error is FormatException) {
return '数据格式错误';
} else {
return '发生未知错误,请稍后重试';
}
}
/// Show error dialog to user
static void showErrorDialog(
BuildContext context,
Object error, {
VoidCallback? onRetry,
}) {
final message = getUserFriendlyMessage(error);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('错误'),
content: Text(message),
actions: [
if (onRetry != null)
TextButton(
onPressed: () {
Navigator.of(context).pop();
onRetry();
},
child: const Text('重试'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('确定'),
),
],
),
);
}
/// Show error snackbar to user
static void showErrorSnackBar(
BuildContext context,
Object error, {
Duration duration = const Duration(seconds: 3),
}) {
final message = getUserFriendlyMessage(error);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: duration,
backgroundColor: Colors.red,
action: SnackBarAction(
label: '关闭',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
}

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

@@ -0,0 +1,148 @@
/// Base exception class for all application exceptions
abstract class AppException implements Exception {
final String message;
final String? details;
final StackTrace? stackTrace;
AppException(this.message, [this.details, this.stackTrace]);
@override
String toString() {
if (details != null) {
return '$runtimeType: $message\nDetails: $details';
}
return '$runtimeType: $message';
}
}
/// Exception thrown when network operations fail
class NetworkException extends AppException {
final int? statusCode;
final String? url;
NetworkException(
super.message, [
super.details,
super.stackTrace,
this.statusCode,
this.url,
]);
@override
String toString() {
final buffer = StringBuffer('NetworkException: $message');
if (statusCode != null) {
buffer.write('\nStatus Code: $statusCode');
}
if (url != null) {
buffer.write('\nURL: $url');
}
if (details != null) {
buffer.write('\nDetails: $details');
}
return buffer.toString();
}
}
/// Exception thrown when authentication fails
class AuthenticationException extends AppException {
final String? userId;
final AuthenticationFailureReason? reason;
AuthenticationException(
super.message, [
super.details,
super.stackTrace,
this.userId,
this.reason,
]);
@override
String toString() {
final buffer = StringBuffer('AuthenticationException: $message');
if (userId != null) {
buffer.write('\nUser ID: $userId');
}
if (reason != null) {
buffer.write('\nReason: ${reason!.name}');
}
if (details != null) {
buffer.write('\nDetails: $details');
}
return buffer.toString();
}
}
/// Reasons for authentication failure
enum AuthenticationFailureReason {
invalidCredentials,
sessionExpired,
networkError,
serverError,
unknown,
}
/// Exception thrown when parsing fails
class ParseException extends AppException {
final String? source;
final String? expectedFormat;
ParseException(
super.message, [
super.details,
super.stackTrace,
this.source,
this.expectedFormat,
]);
@override
String toString() {
final buffer = StringBuffer('ParseException: $message');
if (expectedFormat != null) {
buffer.write('\nExpected Format: $expectedFormat');
}
if (source != null && source!.length <= 100) {
buffer.write('\nSource: $source');
} else if (source != null) {
buffer.write('\nSource: ${source!.substring(0, 100)}...');
}
if (details != null) {
buffer.write('\nDetails: $details');
}
return buffer.toString();
}
}
/// Exception thrown when validation fails
class ValidationException extends AppException {
final String? fieldName;
final dynamic invalidValue;
final List<String>? validationRules;
ValidationException(
super.message, [
super.details,
super.stackTrace,
this.fieldName,
this.invalidValue,
this.validationRules,
]);
@override
String toString() {
final buffer = StringBuffer('ValidationException: $message');
if (fieldName != null) {
buffer.write('\nField: $fieldName');
}
if (invalidValue != null) {
buffer.write('\nInvalid Value: $invalidValue');
}
if (validationRules != null && validationRules!.isNotEmpty) {
buffer.write('\nValidation Rules: ${validationRules!.join(", ")}');
}
if (details != null) {
buffer.write('\nDetails: $details');
}
return buffer.toString();
}
}

View File

@@ -0,0 +1,76 @@
import 'dart:math';
/// 重试处理器,支持指数退避策略
class RetryHandler {
static const int maxRetries = 3;
static const Duration initialDelay = Duration(seconds: 1);
static const double exponentialBase = 2.0;
/// 执行带重试的异步操作
///
/// [operation] 要执行的异步操作
/// [retryIf] 可选的条件函数返回true时才重试
/// [maxAttempts] 最大尝试次数默认为3次
/// [onRetry] 可选的重试回调,参数为当前尝试次数和错误
static Future<T> retry<T>({
required Future<T> Function() operation,
bool Function(dynamic error)? retryIf,
int maxAttempts = maxRetries,
void Function(int attempt, dynamic error)? onRetry,
}) async {
int attempt = 0;
dynamic lastError;
while (true) {
try {
attempt++;
return await operation();
} catch (e) {
lastError = e;
// 检查是否应该重试
if (attempt >= maxAttempts || (retryIf != null && !retryIf(e))) {
rethrow;
}
// 计算延迟时间(指数退避)
final delay = initialDelay * pow(exponentialBase, attempt - 1);
// 调用重试回调
if (onRetry != null) {
onRetry(attempt, e);
}
// 等待后重试
await Future.delayed(delay);
}
}
}
/// 判断错误是否应该重试(网络相关错误)
static bool shouldRetryOnError(dynamic error) {
// 可以根据具体错误类型判断是否应该重试
// 例如:网络超时、连接失败等应该重试
// 认证失败、参数错误等不应该重试
final errorStr = error.toString().toLowerCase();
// 应该重试的错误类型
if (errorStr.contains('timeout') ||
errorStr.contains('connection') ||
errorStr.contains('network') ||
errorStr.contains('socket')) {
return true;
}
// 不应该重试的错误类型
if (errorStr.contains('authentication') ||
errorStr.contains('unauthorized') ||
errorStr.contains('forbidden') ||
errorStr.contains('invalid')) {
return false;
}
// 默认重试
return true;
}
}

View File

@@ -0,0 +1,263 @@
import 'package:flutter/foundation.dart';
import '../models/user_credentials.dart';
import '../providers/auth_provider.dart';
/// Session manager for handling app startup and session restoration
///
/// Provides utilities for:
/// - Checking if saved credentials exist
/// - Attempting to restore previous session
/// - Handling session expiration
///
/// Usage example:
/// ```dart
/// final sessionManager = SessionManager(authProvider: authProvider);
///
/// // Check if session can be restored
/// final canRestore = await sessionManager.canRestoreSession();
///
/// // Attempt to restore session
/// final restored = await sessionManager.restoreSession();
///
/// // Handle session expiration
/// await sessionManager.handleSessionExpired();
/// ```
class SessionManager {
final AuthProvider _authProvider;
SessionManager({required AuthProvider authProvider})
: _authProvider = authProvider;
/// Check if saved credentials exist
///
/// Returns true if credentials are stored, false otherwise
/// Does not validate if the session is still active
Future<bool> hasSavedCredentials() async {
try {
final credentials = await UserCredentials.loadSecurely();
return credentials != null;
} catch (e) {
debugPrint('Error checking saved credentials: $e');
return false;
}
}
/// Check if session can be restored
///
/// Checks if saved credentials exist and are valid
/// Returns true if session restoration should be attempted
Future<bool> canRestoreSession() async {
return await hasSavedCredentials();
}
/// Attempt to restore session from saved credentials
///
/// Loads credentials from secure storage and attempts login
/// Returns SessionRestoreResult with status and details
Future<SessionRestoreResult> restoreSession() async {
try {
// Check if credentials exist
final hasCredentials = await hasSavedCredentials();
if (!hasCredentials) {
return SessionRestoreResult(
success: false,
reason: SessionRestoreFailureReason.noCredentials,
message: '未找到保存的登录凭证',
);
}
// Attempt to restore session using AuthProvider
final restored = await _authProvider.restoreSession();
if (restored) {
return SessionRestoreResult(success: true, message: '会话恢复成功');
} else {
// Check the error message from auth provider
final errorMessage = _authProvider.errorMessage;
final reason = _determineFailureReason(errorMessage);
return SessionRestoreResult(
success: false,
reason: reason,
message: errorMessage ?? '会话恢复失败',
);
}
} catch (e) {
debugPrint('Error restoring session: $e');
return SessionRestoreResult(
success: false,
reason: SessionRestoreFailureReason.unknown,
message: '会话恢复出错: $e',
);
}
}
/// Handle session expiration
///
/// Clears current session and credentials
/// Should be called when session is detected as expired
Future<void> handleSessionExpired() async {
try {
await _authProvider.logout();
debugPrint('Session expired and cleared');
} catch (e) {
debugPrint('Error handling session expiration: $e');
}
}
/// Clear saved session data
///
/// Removes all saved credentials and session information
/// Useful for logout or when user wants to clear data
Future<void> clearSession() async {
try {
await _authProvider.logout();
debugPrint('Session cleared successfully');
} catch (e) {
debugPrint('Error clearing session: $e');
}
}
/// Validate current session
///
/// Checks if the current session is still valid
/// Returns true if session is active and healthy
Future<bool> validateSession() async {
try {
if (!_authProvider.isAuthenticated) {
return false;
}
return await _authProvider.checkSession();
} catch (e) {
debugPrint('Error validating session: $e');
return false;
}
}
/// Get session status
///
/// Returns current session status information
SessionStatus getSessionStatus() {
return SessionStatus(
isAuthenticated: _authProvider.isAuthenticated,
authState: _authProvider.state,
hasConnection: _authProvider.connection != null,
errorMessage: _authProvider.errorMessage,
);
}
/// Determine failure reason from error message
SessionRestoreFailureReason _determineFailureReason(String? errorMessage) {
if (errorMessage == null) {
return SessionRestoreFailureReason.unknown;
}
if (errorMessage.contains('密码错误') || errorMessage.contains('凭证')) {
return SessionRestoreFailureReason.invalidCredentials;
} else if (errorMessage.contains('网络') || errorMessage.contains('连接')) {
return SessionRestoreFailureReason.networkError;
} else if (errorMessage.contains('过期')) {
return SessionRestoreFailureReason.sessionExpired;
} else {
return SessionRestoreFailureReason.unknown;
}
}
}
/// Result of session restoration attempt
class SessionRestoreResult {
final bool success;
final SessionRestoreFailureReason? reason;
final String message;
SessionRestoreResult({
required this.success,
this.reason,
required this.message,
});
@override
String toString() {
return 'SessionRestoreResult(success: $success, reason: $reason, message: $message)';
}
}
/// Reasons why session restoration might fail
enum SessionRestoreFailureReason {
/// No saved credentials found
noCredentials,
/// Saved credentials are invalid
invalidCredentials,
/// Session has expired
sessionExpired,
/// Network connection error
networkError,
/// Unknown error
unknown,
}
/// Current session status information
class SessionStatus {
final bool isAuthenticated;
final AuthState authState;
final bool hasConnection;
final String? errorMessage;
SessionStatus({
required this.isAuthenticated,
required this.authState,
required this.hasConnection,
this.errorMessage,
});
/// Check if session is healthy
bool get isHealthy =>
isAuthenticated && hasConnection && errorMessage == null;
@override
String toString() {
return 'SessionStatus('
'isAuthenticated: $isAuthenticated, '
'authState: $authState, '
'hasConnection: $hasConnection, '
'errorMessage: $errorMessage'
')';
}
}
/// Extension methods for SessionRestoreFailureReason
extension SessionRestoreFailureReasonExtension on SessionRestoreFailureReason {
/// Get user-friendly message for the failure reason
String get userMessage {
switch (this) {
case SessionRestoreFailureReason.noCredentials:
return '未找到保存的登录信息,请重新登录';
case SessionRestoreFailureReason.invalidCredentials:
return '登录凭证无效,请重新登录';
case SessionRestoreFailureReason.sessionExpired:
return '会话已过期,请重新登录';
case SessionRestoreFailureReason.networkError:
return '网络连接失败,请检查网络后重试';
case SessionRestoreFailureReason.unknown:
return '会话恢复失败,请重新登录';
}
}
/// Check if user should be prompted to login again
bool get shouldPromptLogin {
switch (this) {
case SessionRestoreFailureReason.noCredentials:
case SessionRestoreFailureReason.invalidCredentials:
case SessionRestoreFailureReason.sessionExpired:
return true;
case SessionRestoreFailureReason.networkError:
case SessionRestoreFailureReason.unknown:
return false; // User might want to retry
}
}
}

View File

@@ -0,0 +1,194 @@
import 'dart:math';
import '../models/questionnaire.dart';
/// Text generator for evaluation questionnaires
/// Generates appropriate text responses based on question type
class TextGenerator {
static final Random _random = Random();
// 启发类文案库
static const List<String> inspirationTexts = [
"老师认真负责的态度和丰富的讲课内容,让我明白了扎实的知识积累对学习的重要性",
"老师能够深入了解学生的学习状况,启发我学会了关注细节、因材施教的道理",
"老师授课有条理有重点,教会我做事要分清主次、抓住关键的思维方法",
"老师善于用凝练的语言表达复杂内容,让我学会了如何提炼要点、化繁为简",
"老师对深奥现象解释得通俗易懂,启发我认识到深入浅出是一种重要的能力",
"老师采用多种教学方式让学生更好接受知识,让我明白了方法灵活运用的重要性",
"老师既严格要求又鼓励学生发言,教会我严慈相济、宽严并济的处事原则",
"老师能够调动学生的积极性,启发我懂得了激发他人潜能和主动性的价值",
"老师课堂气氛活跃但不失严谨,让我理解了轻松与高效可以兼得的道理",
"老师治学严谨、循循善诱的风格,激励我要保持谦逊认真的学习态度和钻研精神",
"老师对学科的热爱和投入,让我感受到保持热情对做好任何事情的重要意义",
"老师善于联系实际讲解理论知识,启发我学会了理论联系实际的思维方式",
"老师注重培养学生的自主学习能力,让我明白了授人以渔的教育真谛",
"老师对每个问题的耐心解答,教会我做事要有耐心和责任心",
"老师在课堂上的幽默感,让我懂得了适度的轻松能够提高工作和学习效率",
"老师严格的课堂管理,启发我认识到纪律和规则对集体活动的重要性",
"老师丰富的专业知识储备,激励我要不断充实自己、拓宽知识面",
"老师对学生的一视同仁,让我理解了公平公正待人的重要价值",
"老师善于鼓励和肯定学生,教会我正面激励对他人成长的积极作用",
"老师清晰的逻辑思维,启发我学会了有条理地思考和表达问题",
"老师对教学的精心准备,让我明白了充分准备是做好工作的前提",
"老师善于归纳总结重点,教会我抓住事物本质和核心的思维能力",
"老师对学生问题的重视,启发我懂得了倾听和尊重他人意见的重要性",
"老师灵活的教学节奏把握,让我学会了根据实际情况灵活调整的智慧",
"老师富有感染力的授课方式,教会我热情和真诚能够打动他人",
"老师注重学生的全面发展,启发我认识到综合素质培养的重要性",
"老师对细节的关注,让我明白了细节决定成败的道理",
"老师善于启发学生独立思考,教会我批判性思维和质疑精神的可贵",
"老师持续学习、与时俱进的态度,激励我要保持终身学习的理念",
"老师对学生的关心和帮助,让我理解了教书育人、为人师表的深刻内涵",
];
// 建议类文案库
static const List<String> suggestionTexts = [
'',
'没有',
"老师讲课很好,很认真负责,我没有什么建议,希望老师继续保持现有的教学方式",
"老师授课认真,课堂效率高,我觉得一切都很好,暂时没有什么意见和建议",
"老师上课既幽默又严格,教学方法很适合我们,没有需要改进的地方",
"老师治学严谨,循循善诱,对老师的授课我非常满意,请老师保持这种教学状态",
"老师授课有条理有重点,我认为已经做得很到位了,没有什么建议可提",
"老师善于用凝练的语言讲解复杂内容,教学方式很好,希望老师继续发扬优点",
"老师讲课内容详细,条理清晰,我觉得没有什么需要调整的地方,一切都很棒",
"老师讲授认真,内容丰富,我对教学方式非常认可,请老师保持现在的风格",
"老师对待教学认真负责,能够调动学生积极性,我没有什么意见,希望老师继续保持",
"老师课堂效率高,气氛活跃,整节课学下来很有收获,暂时想不到需要改进的地方",
"老师教学态度端正,讲课思路清晰,我觉得非常好,没有什么意见和建议",
"老师授课生动有趣,深入浅出,对老师的教学我很满意,请老师保持下去",
"老师对学生要求严格但不失关怀,教学方法得当,我没有什么建议可提",
"老师讲课重点突出,内容充实,我认为一切都很好,希望老师继续保持",
"老师课堂互动性强,能照顾到每个学生,我觉得没有需要改进的地方",
"老师备课充分,讲解透彻,对老师的授课非常认可,暂时没有什么意见",
"老师教学经验丰富,方法多样,我觉得已经很优秀了,请老师保持现状",
"老师语言表达清晰,逻辑性强,我没有什么建议,希望老师继续发扬",
"老师授课节奏把握得很好,我认为非常合适,没有什么需要调整的",
"老师对待学生耐心负责,教学效果显著,我很满意,请老师保持",
"老师讲课富有激情,能感染学生,我觉得很好,暂时没有什么意见",
"老师专业知识扎实,讲解到位,对老师的教学我非常认可,没有建议",
"老师善于引导学生思考,启发性强,我认为一切都很好,请老师保持",
"老师课堂管理有序,教学效率高,我觉得没有什么需要改进的地方",
"老师授课风格独特,深受学生喜爱,我没有什么意见和建议",
"老师讲课深入浅出,通俗易懂,我认为非常好,希望老师继续保持",
"老师对学生一视同仁,公平公正,我很满意老师的教学方式",
"老师教学方法科学合理,效果突出,我觉得没有需要调整的地方",
"老师认真批改作业,及时反馈,对老师的工作我非常认可,请保持",
"老师课堂内容丰富多彩,讲解细致入微,我没有什么建议,一切都很好",
];
// 总体评价文案库
static const List<String> overallTexts = [
"老师讲课认真负责,课程内容充实丰富,理论与实践结合得很好,让我收获颇丰,对专业知识有了更深入的理解",
"老师授课条理清晰,课程设置合理,由浅入深,循序渐进,学习过程中既有挑战性又能跟上节奏",
"老师教学方法灵活多样,课程内容非常实用,学到的知识能够应用到实际中,让我感受到了学以致用的乐趣",
"老师讲课生动有趣,课程内容丰富多彩,涵盖面广,开阔了我的视野,激发了我对这个领域更浓厚的兴趣",
"老师治学严谨,循循善诱,通过这门课程让我建立了完整的知识体系,培养了逻辑思维能力和分析问题的能力",
"老师授课重点突出,课程难度适中,既巩固了基础知识,又拓展了深度内容,满足了我的学习需求",
"老师善于启发学生思考,课程注重培养实践能力和创新思维,让我不仅学到了知识,更学会了如何解决问题",
"老师讲解详细透彻,课程安排紧凑合理,通过学习让我对该学科有了系统而全面的认识",
"老师课堂气氛活跃,能调动学生积极性,这门课程很有启发性,培养了我的自主学习能力和探索精神",
"老师教学认真,内容讲授清晰明确,课程与时俱进,紧跟学科发展,整体学习体验非常好,让我受益匪浅",
"老师备课充分,课程内容环环相扣,逻辑严密,让我掌握了扎实的专业基础知识",
"老师授课富有激情,课程设计新颖独特,学习过程充满乐趣,让我对学习保持了浓厚兴趣",
"老师对学生认真负责,课程作业设置合理,既能巩固知识又不会过于繁重,学习效果很好",
"老师讲课深入浅出,课程内容由易到难,知识点讲解透彻,让我能够循序渐进地掌握知识",
"老师善于互动交流,课程注重学生参与,让我在积极的课堂氛围中提高了学习效率",
"老师专业素养高,课程内容前沿实用,让我了解到了学科的最新发展动态和应用前景",
"老师授课方式灵活,课程形式多样,既有理论讲解又有案例分析,让学习更加立体生动",
"老师对学生耐心指导,课程考核方式合理,既注重过程又关注结果,让我全面提升了能力",
"老师治学态度严谨,课程内容系统完整,帮助我构建了完善的知识框架和学科思维",
"老师善于举例说明,课程理论联系实际,让抽象的概念变得具体易懂,提高了我的理解能力",
"老师课堂管理有序,课程进度把握得当,既保证了教学质量又照顾到了学生的接受能力",
"老师讲课条理分明,课程重难点突出,让我能够抓住学习的关键,提高了学习效率",
"老师对学生要求严格,课程训练扎实有效,让我养成了良好的学习习惯和严谨的学习态度",
"老师授课语言生动,课程内容引人入胜,每节课都能让我保持高度的专注和学习热情",
"老师善于总结归纳,课程知识点梳理清晰,帮助我建立了清晰的知识脉络和记忆框架",
"老师注重能力培养,课程不仅传授知识更注重方法,让我掌握了学习和研究的基本方法",
"老师课堂效果显著,课程学习收获很大,不仅提升了专业水平也拓宽了思维视野",
"老师教学经验丰富,课程设计科学合理,让我在轻松愉快的氛围中完成了学习任务",
"老师对学生关怀备至,课程教学以学生为中心,充分考虑了我们的实际需求和接受能力",
"老师讲课精彩纷呈,课程内容充实饱满,每次上课都有新的收获和感悟,让我的学习充满期待",
];
/// Generate text based on question type
/// Returns a random text from the appropriate text library
String generate(QuestionType type) {
String text;
switch (type) {
case QuestionType.inspiration:
text = inspirationTexts[_random.nextInt(inspirationTexts.length)];
break;
case QuestionType.suggestion:
text = suggestionTexts[_random.nextInt(suggestionTexts.length)];
break;
case QuestionType.overall:
text = overallTexts[_random.nextInt(overallTexts.length)];
break;
case QuestionType.general:
// For general type, use overall texts as fallback
text = overallTexts[_random.nextInt(overallTexts.length)];
break;
}
// Apply text processing rules
text = _removeSpaces(text);
text = _ensureMinLength(text);
return text;
}
/// Ensure text has at least 4 characters
/// If text is shorter, pad with appropriate content
String _ensureMinLength(String text) {
if (text.length >= 4) {
return text;
}
// For very short texts like "无" or "没有", they are already valid
// Just return as is since Chinese characters count as valid
return text;
}
/// Remove all spaces from text
String _removeSpaces(String text) {
return text.replaceAll(' ', '');
}
/// Validate if text meets all requirements
/// Returns true if text is valid, false otherwise
bool validate(String text) {
// Check minimum length (at least 4 characters)
if (text.length < 4) {
return false;
}
// Check for spaces (should not contain any)
if (text.contains(' ')) {
return false;
}
// Check for 3 or more consecutive identical characters
if (_hasConsecutiveChars(text)) {
return false;
}
return true;
}
/// Check if text has 3 or more consecutive identical characters
/// Returns true if found, false otherwise
bool _hasConsecutiveChars(String text) {
if (text.length < 3) {
return false;
}
for (int i = 0; i < text.length - 2; i++) {
if (text[i] == text[i + 1] && text[i] == text[i + 2]) {
return true;
}
}
return false;
}
}

2
lib/widgets/.gitkeep Normal file
View File

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

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
/// Confirmation dialog widget
///
/// Displays a confirmation dialog with customizable title, content, and buttons
class ConfirmDialog extends StatelessWidget {
final String title;
final String content;
final String confirmText;
final String cancelText;
final bool isDangerous;
const ConfirmDialog({
super.key,
required this.title,
required this.content,
this.confirmText = '确认',
this.cancelText = '取消',
this.isDangerous = false,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelText),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: isDangerous
? TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
)
: null,
child: Text(confirmText),
),
],
);
}
/// Show confirmation dialog
static Future<bool> show(
BuildContext context, {
required String title,
required String content,
String confirmText = '确认',
String cancelText = '取消',
bool isDangerous = false,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => ConfirmDialog(
title: title,
content: content,
confirmText: confirmText,
cancelText: cancelText,
isDangerous: isDangerous,
),
);
return result ?? false;
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import '../models/course.dart';
/// Course card widget
///
/// Displays course information in a card format
/// Shows course name, teacher, and evaluation status
class CourseCard extends StatelessWidget {
final Course course;
const CourseCard({super.key, required this.course});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Course name and status
Row(
children: [
Expanded(
child: Text(
course.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
_buildStatusChip(context),
],
),
const SizedBox(height: 8),
// Teacher info
Row(
children: [
Icon(
Icons.person,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
course.teacher,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 4),
// Evaluated people info
Row(
children: [
Icon(
Icons.group,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
course.evaluatedPeople,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
);
}
Widget _buildStatusChip(BuildContext context) {
if (course.isEvaluated) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle, size: 14, color: Colors.green),
const SizedBox(width: 4),
Text(
'已评',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
);
} else {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.pending,
size: 14,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Text(
'待评',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
/// Error dialog widget
///
/// Displays an error message with an icon and optional action button
class ErrorDialog extends StatelessWidget {
final String title;
final String message;
final String? actionText;
final VoidCallback? onAction;
const ErrorDialog({
super.key,
required this.title,
required this.message,
this.actionText,
this.onAction,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
icon: Icon(
Icons.error_outline,
size: 48,
color: Theme.of(context).colorScheme.error,
),
title: Text(title),
content: Text(message),
actions: [
if (actionText != null && onAction != null)
TextButton(
onPressed: () {
Navigator.of(context).pop();
onAction!();
},
child: Text(actionText!),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
],
);
}
/// Show error dialog
static Future<void> show(
BuildContext context, {
required String title,
required String message,
String? actionText,
VoidCallback? onAction,
}) {
return showDialog(
context: context,
builder: (context) => ErrorDialog(
title: title,
message: message,
actionText: actionText,
onAction: onAction,
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
/// Loading indicator widget
///
/// Displays a circular progress indicator with optional message
class LoadingIndicator extends StatelessWidget {
final String? message;
final double size;
const LoadingIndicator({super.key, this.message, this.size = 40});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(strokeWidth: size / 10),
),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
],
),
);
}
}