init
This commit is contained in:
133
client/lib/core/config/environment.dart
Normal file
133
client/lib/core/config/environment.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
/// 环境配置
|
||||
enum Environment {
|
||||
development,
|
||||
staging,
|
||||
production,
|
||||
}
|
||||
|
||||
/// 环境配置管理
|
||||
class EnvironmentConfig {
|
||||
static Environment _currentEnvironment = Environment.development;
|
||||
|
||||
/// 获取当前环境
|
||||
static Environment get current => _currentEnvironment;
|
||||
|
||||
/// 设置当前环境
|
||||
static void setEnvironment(Environment env) {
|
||||
_currentEnvironment = env;
|
||||
}
|
||||
|
||||
/// 从字符串设置环境
|
||||
static void setEnvironmentFromString(String? envString) {
|
||||
switch (envString?.toLowerCase()) {
|
||||
case 'production':
|
||||
case 'prod':
|
||||
_currentEnvironment = Environment.production;
|
||||
break;
|
||||
case 'staging':
|
||||
case 'stage':
|
||||
_currentEnvironment = Environment.staging;
|
||||
break;
|
||||
case 'development':
|
||||
case 'dev':
|
||||
default:
|
||||
_currentEnvironment = Environment.development;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前环境的API基础URL
|
||||
static String get baseUrl {
|
||||
switch (_currentEnvironment) {
|
||||
case Environment.production:
|
||||
return 'https://loukao.cn/api/v1';
|
||||
case Environment.staging:
|
||||
return 'http://localhost:8080/api/v1';
|
||||
case Environment.development:
|
||||
default:
|
||||
// 开发环境:localhost 用于 Web,10.0.2.2 用于 Android 模拟器
|
||||
return const String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://localhost:8080/api/v1',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取环境名称
|
||||
static String get environmentName {
|
||||
switch (_currentEnvironment) {
|
||||
case Environment.production:
|
||||
return 'Production';
|
||||
case Environment.staging:
|
||||
return 'Staging';
|
||||
case Environment.development:
|
||||
return 'Development';
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否为开发环境
|
||||
static bool get isDevelopment => _currentEnvironment == Environment.development;
|
||||
|
||||
/// 是否为生产环境
|
||||
static bool get isProduction => _currentEnvironment == Environment.production;
|
||||
|
||||
/// 是否为预发布环境
|
||||
static bool get isStaging => _currentEnvironment == Environment.staging;
|
||||
|
||||
/// 开发环境配置
|
||||
static const Map<String, String> developmentConfig = {
|
||||
'baseUrl': 'http://localhost:8080/api/v1',
|
||||
'baseUrlAndroid': 'http://10.0.2.2:8080/api/v1',
|
||||
'wsUrl': 'ws://localhost:8080/ws',
|
||||
};
|
||||
|
||||
/// 预发布环境配置
|
||||
static const Map<String, String> stagingConfig = {
|
||||
'baseUrl': 'https://loukao.cn/api/v1',
|
||||
'wsUrl': 'ws://your-staging-domain.com/ws',
|
||||
};
|
||||
|
||||
/// 生产环境配置
|
||||
static const Map<String, String> productionConfig = {
|
||||
'baseUrl': 'https://loukao.cn/api/v1',
|
||||
'wsUrl': 'ws://your-production-domain.com/ws',
|
||||
};
|
||||
|
||||
/// 获取当前环境配置
|
||||
static Map<String, String> get config {
|
||||
switch (_currentEnvironment) {
|
||||
case Environment.production:
|
||||
return productionConfig;
|
||||
case Environment.staging:
|
||||
return stagingConfig;
|
||||
case Environment.development:
|
||||
default:
|
||||
return developmentConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取WebSocket URL
|
||||
static String get wsUrl {
|
||||
return config['wsUrl'] ?? '';
|
||||
}
|
||||
|
||||
/// 获取连接超时时间(毫秒)
|
||||
static int get connectTimeout {
|
||||
return isProduction ? 10000 : 30000;
|
||||
}
|
||||
|
||||
/// 获取接收超时时间(毫秒)
|
||||
static int get receiveTimeout {
|
||||
return isProduction ? 10000 : 30000;
|
||||
}
|
||||
|
||||
/// 是否启用日志
|
||||
static bool get enableLogging {
|
||||
return !isProduction;
|
||||
}
|
||||
|
||||
/// 是否启用调试模式
|
||||
static bool get debugMode {
|
||||
return isDevelopment;
|
||||
}
|
||||
}
|
||||
88
client/lib/core/constants/app_constants.dart
Normal file
88
client/lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import '../config/environment.dart';
|
||||
|
||||
/// 应用常量配置
|
||||
class AppConstants {
|
||||
// 应用信息
|
||||
static const String appName = 'AI英语学习';
|
||||
static const String appVersion = '1.0.0';
|
||||
|
||||
// API配置 - 从环境配置获取
|
||||
static String get baseUrl => EnvironmentConfig.baseUrl;
|
||||
static int get connectTimeout => EnvironmentConfig.connectTimeout;
|
||||
static int get receiveTimeout => EnvironmentConfig.receiveTimeout;
|
||||
|
||||
// 存储键名
|
||||
static const String accessTokenKey = 'access_token';
|
||||
static const String refreshTokenKey = 'refresh_token';
|
||||
static const String userInfoKey = 'user_info';
|
||||
static const String settingsKey = 'app_settings';
|
||||
|
||||
// 分页配置
|
||||
static const int defaultPageSize = 20;
|
||||
static const int maxPageSize = 100;
|
||||
|
||||
// 学习配置
|
||||
static const int dailyWordGoal = 50;
|
||||
static const int maxRetryAttempts = 3;
|
||||
static const Duration studySessionDuration = Duration(minutes: 25);
|
||||
|
||||
// 音频配置
|
||||
static const double defaultPlaybackSpeed = 1.0;
|
||||
static const double minPlaybackSpeed = 0.5;
|
||||
static const double maxPlaybackSpeed = 2.0;
|
||||
|
||||
// 图片配置
|
||||
static const int maxImageSize = 5 * 1024 * 1024; // 5MB
|
||||
static const List<String> supportedImageFormats = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
|
||||
// 缓存配置
|
||||
static const Duration cacheExpiration = Duration(hours: 24);
|
||||
static const int maxCacheSize = 100 * 1024 * 1024; // 100MB
|
||||
}
|
||||
|
||||
/// 路由常量
|
||||
class RouteConstants {
|
||||
static const String splash = '/splash';
|
||||
static const String onboarding = '/onboarding';
|
||||
static const String login = '/login';
|
||||
static const String register = '/register';
|
||||
static const String home = '/home';
|
||||
static const String profile = '/profile';
|
||||
static const String vocabulary = '/vocabulary';
|
||||
static const String vocabularyTest = '/vocabulary/test';
|
||||
static const String listening = '/listening';
|
||||
static const String reading = '/reading';
|
||||
static const String writing = '/writing';
|
||||
static const String speaking = '/speaking';
|
||||
static const String settings = '/settings';
|
||||
}
|
||||
|
||||
/// 学习等级常量
|
||||
enum LearningLevel {
|
||||
beginner('beginner', '初级'),
|
||||
intermediate('intermediate', '中级'),
|
||||
advanced('advanced', '高级');
|
||||
|
||||
const LearningLevel(this.value, this.label);
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 词库类型常量
|
||||
enum VocabularyType {
|
||||
elementary('elementary', '小学'),
|
||||
junior('junior', '初中'),
|
||||
senior('senior', '高中'),
|
||||
cet4('cet4', '四级'),
|
||||
cet6('cet6', '六级'),
|
||||
toefl('toefl', '托福'),
|
||||
ielts('ielts', '雅思'),
|
||||
business('business', '商务'),
|
||||
daily('daily', '日常');
|
||||
|
||||
const VocabularyType(this.value, this.label);
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
}
|
||||
299
client/lib/core/errors/app_error.dart
Normal file
299
client/lib/core/errors/app_error.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
/// 应用错误基类
|
||||
abstract class AppError implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
final dynamic originalError;
|
||||
|
||||
const AppError({
|
||||
required this.message,
|
||||
this.code,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AppError(message: $message, code: $code)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络错误
|
||||
class NetworkError extends AppError {
|
||||
const NetworkError({
|
||||
required super.message,
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
|
||||
factory NetworkError.connectionTimeout() {
|
||||
return const NetworkError(
|
||||
message: '连接超时,请检查网络连接',
|
||||
code: 'CONNECTION_TIMEOUT',
|
||||
);
|
||||
}
|
||||
|
||||
factory NetworkError.noInternet() {
|
||||
return const NetworkError(
|
||||
message: '网络连接不可用,请检查网络设置',
|
||||
code: 'NO_INTERNET',
|
||||
);
|
||||
}
|
||||
|
||||
factory NetworkError.serverError(int statusCode, [String? message]) {
|
||||
return NetworkError(
|
||||
message: message ?? '服务器错误 ($statusCode)',
|
||||
code: 'SERVER_ERROR_$statusCode',
|
||||
);
|
||||
}
|
||||
|
||||
factory NetworkError.unknown([dynamic error]) {
|
||||
return NetworkError(
|
||||
message: '网络请求失败',
|
||||
code: 'UNKNOWN_NETWORK_ERROR',
|
||||
originalError: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证错误
|
||||
class AuthError extends AppError {
|
||||
const AuthError({
|
||||
required super.message,
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
|
||||
factory AuthError.unauthorized() {
|
||||
return const AuthError(
|
||||
message: '未授权访问,请重新登录',
|
||||
code: 'UNAUTHORIZED',
|
||||
);
|
||||
}
|
||||
|
||||
factory AuthError.tokenExpired() {
|
||||
return const AuthError(
|
||||
message: '登录已过期,请重新登录',
|
||||
code: 'TOKEN_EXPIRED',
|
||||
);
|
||||
}
|
||||
|
||||
factory AuthError.invalidCredentials() {
|
||||
return const AuthError(
|
||||
message: '用户名或密码错误',
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
);
|
||||
}
|
||||
|
||||
factory AuthError.accountLocked() {
|
||||
return const AuthError(
|
||||
message: '账户已被锁定,请联系客服',
|
||||
code: 'ACCOUNT_LOCKED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证错误
|
||||
class ValidationError extends AppError {
|
||||
final Map<String, List<String>>? fieldErrors;
|
||||
|
||||
const ValidationError({
|
||||
required super.message,
|
||||
super.code,
|
||||
super.originalError,
|
||||
this.fieldErrors,
|
||||
});
|
||||
|
||||
factory ValidationError.required(String field) {
|
||||
return ValidationError(
|
||||
message: '$field不能为空',
|
||||
code: 'FIELD_REQUIRED',
|
||||
fieldErrors: {field: ['不能为空']},
|
||||
);
|
||||
}
|
||||
|
||||
factory ValidationError.invalid(String field, String reason) {
|
||||
return ValidationError(
|
||||
message: '$field格式不正确:$reason',
|
||||
code: 'FIELD_INVALID',
|
||||
fieldErrors: {field: [reason]},
|
||||
);
|
||||
}
|
||||
|
||||
factory ValidationError.multiple(Map<String, List<String>> errors) {
|
||||
return ValidationError(
|
||||
message: '表单验证失败',
|
||||
code: 'VALIDATION_FAILED',
|
||||
fieldErrors: errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 业务逻辑错误
|
||||
class BusinessError extends AppError {
|
||||
const BusinessError({
|
||||
required super.message,
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
|
||||
factory BusinessError.notFound(String resource) {
|
||||
return BusinessError(
|
||||
message: '$resource不存在',
|
||||
code: 'RESOURCE_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
factory BusinessError.alreadyExists(String resource) {
|
||||
return BusinessError(
|
||||
message: '$resource已存在',
|
||||
code: 'RESOURCE_ALREADY_EXISTS',
|
||||
);
|
||||
}
|
||||
|
||||
factory BusinessError.operationNotAllowed(String operation) {
|
||||
return BusinessError(
|
||||
message: '不允许执行操作:$operation',
|
||||
code: 'OPERATION_NOT_ALLOWED',
|
||||
);
|
||||
}
|
||||
|
||||
factory BusinessError.quotaExceeded(String resource) {
|
||||
return BusinessError(
|
||||
message: '$resource配额已用完',
|
||||
code: 'QUOTA_EXCEEDED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 存储错误
|
||||
class StorageError extends AppError {
|
||||
const StorageError({
|
||||
required super.message,
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
|
||||
factory StorageError.readFailed(String key) {
|
||||
return StorageError(
|
||||
message: '读取数据失败:$key',
|
||||
code: 'STORAGE_READ_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
factory StorageError.writeFailed(String key) {
|
||||
return StorageError(
|
||||
message: '写入数据失败:$key',
|
||||
code: 'STORAGE_WRITE_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
factory StorageError.notInitialized() {
|
||||
return const StorageError(
|
||||
message: '存储服务未初始化',
|
||||
code: 'STORAGE_NOT_INITIALIZED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件错误
|
||||
class FileError extends AppError {
|
||||
const FileError({
|
||||
required super.message,
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
|
||||
factory FileError.notFound(String path) {
|
||||
return FileError(
|
||||
message: '文件不存在:$path',
|
||||
code: 'FILE_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
factory FileError.accessDenied(String path) {
|
||||
return FileError(
|
||||
message: '文件访问被拒绝:$path',
|
||||
code: 'FILE_ACCESS_DENIED',
|
||||
);
|
||||
}
|
||||
|
||||
factory FileError.formatNotSupported(String format) {
|
||||
return FileError(
|
||||
message: '不支持的文件格式:$format',
|
||||
code: 'FILE_FORMAT_NOT_SUPPORTED',
|
||||
);
|
||||
}
|
||||
|
||||
factory FileError.sizeTooLarge(int size, int maxSize) {
|
||||
return FileError(
|
||||
message: '文件大小超出限制:${size}B > ${maxSize}B',
|
||||
code: 'FILE_SIZE_TOO_LARGE',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 音频错误
|
||||
class AudioError extends AppError {
|
||||
const AudioError({
|
||||
required super.message,
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
|
||||
factory AudioError.playbackFailed() {
|
||||
return const AudioError(
|
||||
message: '音频播放失败',
|
||||
code: 'AUDIO_PLAYBACK_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
factory AudioError.recordingFailed() {
|
||||
return const AudioError(
|
||||
message: '音频录制失败',
|
||||
code: 'AUDIO_RECORDING_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
factory AudioError.permissionDenied() {
|
||||
return const AudioError(
|
||||
message: '音频权限被拒绝',
|
||||
code: 'AUDIO_PERMISSION_DENIED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 学习相关错误
|
||||
class LearningError extends AppError {
|
||||
const LearningError({
|
||||
required super.message,
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
|
||||
factory LearningError.progressNotFound() {
|
||||
return const LearningError(
|
||||
message: '学习进度不存在',
|
||||
code: 'LEARNING_PROGRESS_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
factory LearningError.vocabularyNotFound() {
|
||||
return const LearningError(
|
||||
message: '词汇不存在',
|
||||
code: 'VOCABULARY_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
factory LearningError.testNotCompleted() {
|
||||
return const LearningError(
|
||||
message: '测试未完成',
|
||||
code: 'TEST_NOT_COMPLETED',
|
||||
);
|
||||
}
|
||||
|
||||
factory LearningError.levelNotUnlocked() {
|
||||
return const LearningError(
|
||||
message: '等级未解锁',
|
||||
code: 'LEVEL_NOT_UNLOCKED',
|
||||
);
|
||||
}
|
||||
}
|
||||
62
client/lib/core/errors/app_exception.dart
Normal file
62
client/lib/core/errors/app_exception.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
/// 应用异常基类
|
||||
class AppException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
final dynamic details;
|
||||
|
||||
const AppException(
|
||||
this.message, {
|
||||
this.code,
|
||||
this.details,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AppException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络异常
|
||||
class NetworkException extends AppException {
|
||||
const NetworkException(
|
||||
super.message, {
|
||||
super.code,
|
||||
super.details,
|
||||
});
|
||||
}
|
||||
|
||||
/// 认证异常
|
||||
class AuthException extends AppException {
|
||||
const AuthException(
|
||||
super.message, {
|
||||
super.code,
|
||||
super.details,
|
||||
});
|
||||
}
|
||||
|
||||
/// 服务器异常
|
||||
class ServerException extends AppException {
|
||||
const ServerException(
|
||||
super.message, {
|
||||
super.code,
|
||||
super.details,
|
||||
});
|
||||
}
|
||||
|
||||
/// 缓存异常
|
||||
class CacheException extends AppException {
|
||||
const CacheException(
|
||||
super.message, {
|
||||
super.code,
|
||||
super.details,
|
||||
});
|
||||
}
|
||||
|
||||
/// 验证异常
|
||||
class ValidationException extends AppException {
|
||||
const ValidationException(
|
||||
super.message, {
|
||||
super.code,
|
||||
super.details,
|
||||
});
|
||||
}
|
||||
121
client/lib/core/models/api_response.dart
Normal file
121
client/lib/core/models/api_response.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
/// API响应基础模型
|
||||
class ApiResponse<T> {
|
||||
final bool success;
|
||||
final String message;
|
||||
final T? data;
|
||||
final int? code;
|
||||
final Map<String, dynamic>? errors;
|
||||
|
||||
const ApiResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
this.data,
|
||||
this.code,
|
||||
this.errors,
|
||||
});
|
||||
|
||||
factory ApiResponse.success({
|
||||
required String message,
|
||||
T? data,
|
||||
int? code,
|
||||
}) {
|
||||
return ApiResponse<T>(
|
||||
success: true,
|
||||
message: message,
|
||||
data: data,
|
||||
code: code ?? 200,
|
||||
);
|
||||
}
|
||||
|
||||
factory ApiResponse.error({
|
||||
required String message,
|
||||
int? code,
|
||||
Map<String, dynamic>? errors,
|
||||
}) {
|
||||
return ApiResponse<T>(
|
||||
success: false,
|
||||
message: message,
|
||||
code: code ?? 400,
|
||||
errors: errors,
|
||||
);
|
||||
}
|
||||
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(dynamic)? fromJsonT,
|
||||
) {
|
||||
return ApiResponse<T>(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'] ?? '',
|
||||
data: json['data'] != null && fromJsonT != null
|
||||
? fromJsonT(json['data'])
|
||||
: json['data'],
|
||||
code: json['code'],
|
||||
errors: json['errors'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'success': success,
|
||||
'message': message,
|
||||
'data': data,
|
||||
'code': code,
|
||||
'errors': errors,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ApiResponse{success: $success, message: $message, data: $data, code: $code}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页响应模型
|
||||
class PaginatedResponse<T> {
|
||||
final List<T> data;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int totalPages;
|
||||
final bool hasNext;
|
||||
final bool hasPrevious;
|
||||
|
||||
const PaginatedResponse({
|
||||
required this.data,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
required this.totalPages,
|
||||
required this.hasNext,
|
||||
required this.hasPrevious,
|
||||
});
|
||||
|
||||
factory PaginatedResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Map<String, dynamic>) fromJsonT,
|
||||
) {
|
||||
final List<dynamic> dataList = json['data'] ?? [];
|
||||
return PaginatedResponse<T>(
|
||||
data: dataList.map((item) => fromJsonT(item)).toList(),
|
||||
total: json['total'] ?? 0,
|
||||
page: json['page'] ?? 1,
|
||||
pageSize: json['page_size'] ?? 10,
|
||||
totalPages: json['total_pages'] ?? 0,
|
||||
hasNext: json['has_next'] ?? false,
|
||||
hasPrevious: json['has_previous'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'data': data,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
'total_pages': totalPages,
|
||||
'has_next': hasNext,
|
||||
'has_previous': hasPrevious,
|
||||
};
|
||||
}
|
||||
}
|
||||
280
client/lib/core/models/user_model.dart
Normal file
280
client/lib/core/models/user_model.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'user_model.g.dart';
|
||||
|
||||
/// 用户模型
|
||||
@JsonSerializable()
|
||||
class User {
|
||||
final String id;
|
||||
final String username;
|
||||
final String email;
|
||||
final String? phone;
|
||||
final String? avatar;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final UserProfile? profile;
|
||||
final UserSettings? settings;
|
||||
|
||||
const User({
|
||||
required this.id,
|
||||
required this.username,
|
||||
required this.email,
|
||||
this.phone,
|
||||
this.avatar,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.profile,
|
||||
this.settings,
|
||||
});
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$UserToJson(this);
|
||||
|
||||
User copyWith({
|
||||
String? id,
|
||||
String? username,
|
||||
String? email,
|
||||
String? phone,
|
||||
String? avatar,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
UserProfile? profile,
|
||||
UserSettings? settings,
|
||||
}) {
|
||||
return User(
|
||||
id: id ?? this.id,
|
||||
username: username ?? this.username,
|
||||
email: email ?? this.email,
|
||||
phone: phone ?? this.phone,
|
||||
avatar: avatar ?? this.avatar,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
profile: profile ?? this.profile,
|
||||
settings: settings ?? this.settings,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户资料
|
||||
@JsonSerializable()
|
||||
class UserProfile {
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? phone;
|
||||
final String? bio;
|
||||
final String? avatar;
|
||||
final String? realName;
|
||||
final String? gender;
|
||||
final DateTime? birthday;
|
||||
final String? location;
|
||||
final String? occupation;
|
||||
final String? education;
|
||||
final List<String>? interests;
|
||||
final LearningGoal? learningGoal;
|
||||
final EnglishLevel? currentLevel;
|
||||
final EnglishLevel? targetLevel;
|
||||
final EnglishLevel? englishLevel;
|
||||
final UserSettings? settings;
|
||||
|
||||
const UserProfile({
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.phone,
|
||||
this.bio,
|
||||
this.avatar,
|
||||
this.realName,
|
||||
this.gender,
|
||||
this.birthday,
|
||||
this.location,
|
||||
this.occupation,
|
||||
this.education,
|
||||
this.interests,
|
||||
this.learningGoal,
|
||||
this.currentLevel,
|
||||
this.targetLevel,
|
||||
this.englishLevel,
|
||||
this.settings,
|
||||
});
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> json) => _$UserProfileFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$UserProfileToJson(this);
|
||||
|
||||
UserProfile copyWith({
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? phone,
|
||||
String? bio,
|
||||
String? avatar,
|
||||
String? realName,
|
||||
String? gender,
|
||||
DateTime? birthday,
|
||||
String? location,
|
||||
String? occupation,
|
||||
String? education,
|
||||
List<String>? interests,
|
||||
LearningGoal? learningGoal,
|
||||
EnglishLevel? currentLevel,
|
||||
EnglishLevel? targetLevel,
|
||||
EnglishLevel? englishLevel,
|
||||
UserSettings? settings,
|
||||
}) {
|
||||
return UserProfile(
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
phone: phone ?? this.phone,
|
||||
bio: bio ?? this.bio,
|
||||
avatar: avatar ?? this.avatar,
|
||||
realName: realName ?? this.realName,
|
||||
gender: gender ?? this.gender,
|
||||
birthday: birthday ?? this.birthday,
|
||||
location: location ?? this.location,
|
||||
occupation: occupation ?? this.occupation,
|
||||
education: education ?? this.education,
|
||||
interests: interests ?? this.interests,
|
||||
learningGoal: learningGoal ?? this.learningGoal,
|
||||
currentLevel: currentLevel ?? this.currentLevel,
|
||||
targetLevel: targetLevel ?? this.targetLevel,
|
||||
englishLevel: englishLevel ?? this.englishLevel,
|
||||
settings: settings ?? this.settings,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户设置
|
||||
@JsonSerializable()
|
||||
class UserSettings {
|
||||
final bool notificationsEnabled;
|
||||
final bool soundEnabled;
|
||||
final bool vibrationEnabled;
|
||||
final String language;
|
||||
final String theme;
|
||||
final int dailyGoal;
|
||||
final int dailyWordGoal;
|
||||
final int dailyStudyMinutes;
|
||||
final List<String> reminderTimes;
|
||||
final bool autoPlayAudio;
|
||||
final double audioSpeed;
|
||||
final bool showTranslation;
|
||||
final bool showPronunciation;
|
||||
|
||||
const UserSettings({
|
||||
this.notificationsEnabled = true,
|
||||
this.soundEnabled = true,
|
||||
this.vibrationEnabled = true,
|
||||
this.language = 'zh-CN',
|
||||
this.theme = 'system',
|
||||
this.dailyGoal = 30,
|
||||
this.dailyWordGoal = 20,
|
||||
this.dailyStudyMinutes = 30,
|
||||
this.reminderTimes = const ['09:00', '20:00'],
|
||||
this.autoPlayAudio = true,
|
||||
this.audioSpeed = 1.0,
|
||||
this.showTranslation = true,
|
||||
this.showPronunciation = true,
|
||||
});
|
||||
|
||||
factory UserSettings.fromJson(Map<String, dynamic> json) => _$UserSettingsFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$UserSettingsToJson(this);
|
||||
|
||||
UserSettings copyWith({
|
||||
bool? notificationsEnabled,
|
||||
bool? soundEnabled,
|
||||
bool? vibrationEnabled,
|
||||
String? language,
|
||||
String? theme,
|
||||
int? dailyGoal,
|
||||
int? dailyWordGoal,
|
||||
int? dailyStudyMinutes,
|
||||
List<String>? reminderTimes,
|
||||
bool? autoPlayAudio,
|
||||
double? audioSpeed,
|
||||
bool? showTranslation,
|
||||
bool? showPronunciation,
|
||||
}) {
|
||||
return UserSettings(
|
||||
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
||||
soundEnabled: soundEnabled ?? this.soundEnabled,
|
||||
vibrationEnabled: vibrationEnabled ?? this.vibrationEnabled,
|
||||
language: language ?? this.language,
|
||||
theme: theme ?? this.theme,
|
||||
dailyGoal: dailyGoal ?? this.dailyGoal,
|
||||
dailyWordGoal: dailyWordGoal ?? this.dailyWordGoal,
|
||||
dailyStudyMinutes: dailyStudyMinutes ?? this.dailyStudyMinutes,
|
||||
reminderTimes: reminderTimes ?? this.reminderTimes,
|
||||
autoPlayAudio: autoPlayAudio ?? this.autoPlayAudio,
|
||||
audioSpeed: audioSpeed ?? this.audioSpeed,
|
||||
showTranslation: showTranslation ?? this.showTranslation,
|
||||
showPronunciation: showPronunciation ?? this.showPronunciation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 学习目标
|
||||
enum LearningGoal {
|
||||
@JsonValue('daily_communication')
|
||||
dailyCommunication,
|
||||
@JsonValue('business_english')
|
||||
businessEnglish,
|
||||
@JsonValue('academic_study')
|
||||
academicStudy,
|
||||
@JsonValue('exam_preparation')
|
||||
examPreparation,
|
||||
@JsonValue('travel')
|
||||
travel,
|
||||
@JsonValue('hobby')
|
||||
hobby,
|
||||
}
|
||||
|
||||
/// 英语水平
|
||||
enum EnglishLevel {
|
||||
@JsonValue('beginner')
|
||||
beginner,
|
||||
@JsonValue('elementary')
|
||||
elementary,
|
||||
@JsonValue('intermediate')
|
||||
intermediate,
|
||||
@JsonValue('upper_intermediate')
|
||||
upperIntermediate,
|
||||
@JsonValue('advanced')
|
||||
advanced,
|
||||
@JsonValue('proficient')
|
||||
proficient,
|
||||
@JsonValue('expert')
|
||||
expert,
|
||||
}
|
||||
|
||||
/// 认证响应
|
||||
@JsonSerializable()
|
||||
class AuthResponse {
|
||||
final User user;
|
||||
final String token;
|
||||
final String? refreshToken;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const AuthResponse({
|
||||
required this.user,
|
||||
required this.token,
|
||||
this.refreshToken,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
factory AuthResponse.fromJson(Map<String, dynamic> json) => _$AuthResponseFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$AuthResponseToJson(this);
|
||||
}
|
||||
|
||||
/// Token刷新响应
|
||||
@JsonSerializable()
|
||||
class TokenRefreshResponse {
|
||||
final String token;
|
||||
final String? refreshToken;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const TokenRefreshResponse({
|
||||
required this.token,
|
||||
this.refreshToken,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
factory TokenRefreshResponse.fromJson(Map<String, dynamic> json) => _$TokenRefreshResponseFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TokenRefreshResponseToJson(this);
|
||||
}
|
||||
172
client/lib/core/models/user_model.g.dart
Normal file
172
client/lib/core/models/user_model.g.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
User _$UserFromJson(Map<String, dynamic> json) => User(
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String,
|
||||
email: json['email'] as String,
|
||||
phone: json['phone'] as String?,
|
||||
avatar: json['avatar'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
profile: json['profile'] == null
|
||||
? null
|
||||
: UserProfile.fromJson(json['profile'] as Map<String, dynamic>),
|
||||
settings: json['settings'] == null
|
||||
? null
|
||||
: UserSettings.fromJson(json['settings'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'username': instance.username,
|
||||
'email': instance.email,
|
||||
'phone': instance.phone,
|
||||
'avatar': instance.avatar,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'updatedAt': instance.updatedAt.toIso8601String(),
|
||||
'profile': instance.profile,
|
||||
'settings': instance.settings,
|
||||
};
|
||||
|
||||
UserProfile _$UserProfileFromJson(Map<String, dynamic> json) => UserProfile(
|
||||
firstName: json['firstName'] as String?,
|
||||
lastName: json['lastName'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
bio: json['bio'] as String?,
|
||||
avatar: json['avatar'] as String?,
|
||||
realName: json['realName'] as String?,
|
||||
gender: json['gender'] as String?,
|
||||
birthday: json['birthday'] == null
|
||||
? null
|
||||
: DateTime.parse(json['birthday'] as String),
|
||||
location: json['location'] as String?,
|
||||
occupation: json['occupation'] as String?,
|
||||
education: json['education'] as String?,
|
||||
interests: (json['interests'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
learningGoal:
|
||||
$enumDecodeNullable(_$LearningGoalEnumMap, json['learningGoal']),
|
||||
currentLevel:
|
||||
$enumDecodeNullable(_$EnglishLevelEnumMap, json['currentLevel']),
|
||||
targetLevel:
|
||||
$enumDecodeNullable(_$EnglishLevelEnumMap, json['targetLevel']),
|
||||
englishLevel:
|
||||
$enumDecodeNullable(_$EnglishLevelEnumMap, json['englishLevel']),
|
||||
settings: json['settings'] == null
|
||||
? null
|
||||
: UserSettings.fromJson(json['settings'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UserProfileToJson(UserProfile instance) =>
|
||||
<String, dynamic>{
|
||||
'firstName': instance.firstName,
|
||||
'lastName': instance.lastName,
|
||||
'phone': instance.phone,
|
||||
'bio': instance.bio,
|
||||
'avatar': instance.avatar,
|
||||
'realName': instance.realName,
|
||||
'gender': instance.gender,
|
||||
'birthday': instance.birthday?.toIso8601String(),
|
||||
'location': instance.location,
|
||||
'occupation': instance.occupation,
|
||||
'education': instance.education,
|
||||
'interests': instance.interests,
|
||||
'learningGoal': _$LearningGoalEnumMap[instance.learningGoal],
|
||||
'currentLevel': _$EnglishLevelEnumMap[instance.currentLevel],
|
||||
'targetLevel': _$EnglishLevelEnumMap[instance.targetLevel],
|
||||
'englishLevel': _$EnglishLevelEnumMap[instance.englishLevel],
|
||||
'settings': instance.settings,
|
||||
};
|
||||
|
||||
const _$LearningGoalEnumMap = {
|
||||
LearningGoal.dailyCommunication: 'daily_communication',
|
||||
LearningGoal.businessEnglish: 'business_english',
|
||||
LearningGoal.academicStudy: 'academic_study',
|
||||
LearningGoal.examPreparation: 'exam_preparation',
|
||||
LearningGoal.travel: 'travel',
|
||||
LearningGoal.hobby: 'hobby',
|
||||
};
|
||||
|
||||
const _$EnglishLevelEnumMap = {
|
||||
EnglishLevel.beginner: 'beginner',
|
||||
EnglishLevel.elementary: 'elementary',
|
||||
EnglishLevel.intermediate: 'intermediate',
|
||||
EnglishLevel.upperIntermediate: 'upper_intermediate',
|
||||
EnglishLevel.advanced: 'advanced',
|
||||
EnglishLevel.proficient: 'proficient',
|
||||
EnglishLevel.expert: 'expert',
|
||||
};
|
||||
|
||||
UserSettings _$UserSettingsFromJson(Map<String, dynamic> json) => UserSettings(
|
||||
notificationsEnabled: json['notificationsEnabled'] as bool? ?? true,
|
||||
soundEnabled: json['soundEnabled'] as bool? ?? true,
|
||||
vibrationEnabled: json['vibrationEnabled'] as bool? ?? true,
|
||||
language: json['language'] as String? ?? 'zh-CN',
|
||||
theme: json['theme'] as String? ?? 'system',
|
||||
dailyGoal: (json['dailyGoal'] as num?)?.toInt() ?? 30,
|
||||
dailyWordGoal: (json['dailyWordGoal'] as num?)?.toInt() ?? 20,
|
||||
dailyStudyMinutes: (json['dailyStudyMinutes'] as num?)?.toInt() ?? 30,
|
||||
reminderTimes: (json['reminderTimes'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
const ['09:00', '20:00'],
|
||||
autoPlayAudio: json['autoPlayAudio'] as bool? ?? true,
|
||||
audioSpeed: (json['audioSpeed'] as num?)?.toDouble() ?? 1.0,
|
||||
showTranslation: json['showTranslation'] as bool? ?? true,
|
||||
showPronunciation: json['showPronunciation'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UserSettingsToJson(UserSettings instance) =>
|
||||
<String, dynamic>{
|
||||
'notificationsEnabled': instance.notificationsEnabled,
|
||||
'soundEnabled': instance.soundEnabled,
|
||||
'vibrationEnabled': instance.vibrationEnabled,
|
||||
'language': instance.language,
|
||||
'theme': instance.theme,
|
||||
'dailyGoal': instance.dailyGoal,
|
||||
'dailyWordGoal': instance.dailyWordGoal,
|
||||
'dailyStudyMinutes': instance.dailyStudyMinutes,
|
||||
'reminderTimes': instance.reminderTimes,
|
||||
'autoPlayAudio': instance.autoPlayAudio,
|
||||
'audioSpeed': instance.audioSpeed,
|
||||
'showTranslation': instance.showTranslation,
|
||||
'showPronunciation': instance.showPronunciation,
|
||||
};
|
||||
|
||||
AuthResponse _$AuthResponseFromJson(Map<String, dynamic> json) => AuthResponse(
|
||||
user: User.fromJson(json['user'] as Map<String, dynamic>),
|
||||
token: json['token'] as String,
|
||||
refreshToken: json['refreshToken'] as String?,
|
||||
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AuthResponseToJson(AuthResponse instance) =>
|
||||
<String, dynamic>{
|
||||
'user': instance.user,
|
||||
'token': instance.token,
|
||||
'refreshToken': instance.refreshToken,
|
||||
'expiresAt': instance.expiresAt.toIso8601String(),
|
||||
};
|
||||
|
||||
TokenRefreshResponse _$TokenRefreshResponseFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
TokenRefreshResponse(
|
||||
token: json['token'] as String,
|
||||
refreshToken: json['refreshToken'] as String?,
|
||||
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TokenRefreshResponseToJson(
|
||||
TokenRefreshResponse instance) =>
|
||||
<String, dynamic>{
|
||||
'token': instance.token,
|
||||
'refreshToken': instance.refreshToken,
|
||||
'expiresAt': instance.expiresAt.toIso8601String(),
|
||||
};
|
||||
209
client/lib/core/network/ai_api_service.dart
Normal file
209
client/lib/core/network/ai_api_service.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../services/storage_service.dart';
|
||||
import 'api_endpoints.dart';
|
||||
import '../config/environment.dart';
|
||||
|
||||
/// AI相关API服务
|
||||
class AIApiService {
|
||||
static String get _baseUrl => EnvironmentConfig.baseUrl;
|
||||
|
||||
/// 获取认证头部
|
||||
Map<String, String> _getAuthHeaders() {
|
||||
final storageService = StorageService.instance;
|
||||
final token = storageService.getString(StorageKeys.accessToken);
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
};
|
||||
}
|
||||
|
||||
/// 写作批改
|
||||
Future<Map<String, dynamic>> correctWriting({
|
||||
required String content,
|
||||
required String taskType,
|
||||
}) async {
|
||||
try {
|
||||
final headers = _getAuthHeaders();
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/v1/ai/writing/correct'),
|
||||
headers: headers,
|
||||
body: json.encode({
|
||||
'content': content,
|
||||
'task_type': taskType,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to correct writing: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error correcting writing: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 口语评估
|
||||
Future<Map<String, dynamic>> evaluateSpeaking({
|
||||
required String audioText,
|
||||
required String prompt,
|
||||
}) async {
|
||||
try {
|
||||
final headers = _getAuthHeaders();
|
||||
final response = await http.post(
|
||||
Uri.parse('$_baseUrl/api/v1/ai/speaking/evaluate'),
|
||||
headers: headers,
|
||||
body: json.encode({
|
||||
'audio_text': audioText,
|
||||
'prompt': prompt,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to evaluate speaking: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error evaluating speaking: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取AI使用统计
|
||||
Future<Map<String, dynamic>> getAIUsageStats() async {
|
||||
try {
|
||||
final headers = _getAuthHeaders();
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/v1/ai/stats'),
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to get AI stats: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error getting AI stats: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传音频文件
|
||||
Future<Map<String, dynamic>> uploadAudio(File audioFile) async {
|
||||
try {
|
||||
final storageService = StorageService.instance;
|
||||
final token = storageService.getString(StorageKeys.accessToken);
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$_baseUrl/api/v1/upload/audio'),
|
||||
);
|
||||
|
||||
if (token != null) {
|
||||
request.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
request.files.add(
|
||||
await http.MultipartFile.fromPath('audio', audioFile.path),
|
||||
);
|
||||
|
||||
final streamedResponse = await request.send();
|
||||
final response = await http.Response.fromStream(streamedResponse);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to upload audio: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error uploading audio: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传图片文件
|
||||
Future<Map<String, dynamic>> uploadImage(File imageFile) async {
|
||||
try {
|
||||
final storageService = StorageService.instance;
|
||||
final token = storageService.getString(StorageKeys.accessToken);
|
||||
final request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$_baseUrl/api/v1/upload/image'),
|
||||
);
|
||||
|
||||
if (token != null) {
|
||||
request.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
request.files.add(
|
||||
await http.MultipartFile.fromPath('image', imageFile.path),
|
||||
);
|
||||
|
||||
final streamedResponse = await request.send();
|
||||
final response = await http.Response.fromStream(streamedResponse);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to upload image: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error uploading image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除文件
|
||||
Future<Map<String, dynamic>> deleteFile(String fileId) async {
|
||||
try {
|
||||
final headers = _getAuthHeaders();
|
||||
final response = await http.delete(
|
||||
Uri.parse('$_baseUrl/api/v1/upload/file/$fileId'),
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to delete file: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error deleting file: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取文件信息
|
||||
Future<Map<String, dynamic>> getFileInfo(String fileId) async {
|
||||
try {
|
||||
final headers = _getAuthHeaders();
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/v1/upload/file/$fileId'),
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to get file info: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error getting file info: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取上传统计
|
||||
Future<Map<String, dynamic>> getUploadStats({int days = 30}) async {
|
||||
try {
|
||||
final headers = _getAuthHeaders();
|
||||
final response = await http.get(
|
||||
Uri.parse('$_baseUrl/api/v1/upload/stats?days=$days'),
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to get upload stats: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error getting upload stats: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
252
client/lib/core/network/api_client.dart
Normal file
252
client/lib/core/network/api_client.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/navigation_service.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
|
||||
/// API客户端配置
|
||||
class ApiClient {
|
||||
static ApiClient? _instance;
|
||||
late Dio _dio;
|
||||
late StorageService _storageService;
|
||||
|
||||
ApiClient._internal() {
|
||||
_dio = Dio();
|
||||
}
|
||||
|
||||
static Future<ApiClient> getInstance() async {
|
||||
if (_instance == null) {
|
||||
_instance = ApiClient._internal();
|
||||
_instance!._storageService = await StorageService.getInstance();
|
||||
await _instance!._setupInterceptors();
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static ApiClient get instance {
|
||||
if (_instance == null) {
|
||||
throw Exception('ApiClient not initialized. Call ApiClient.getInstance() first.');
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Dio get dio => _dio;
|
||||
|
||||
/// 配置拦截器
|
||||
Future<void> _setupInterceptors() async {
|
||||
// 基础配置
|
||||
_dio.options = BaseOptions(
|
||||
baseUrl: AppConstants.baseUrl,
|
||||
connectTimeout: Duration(milliseconds: AppConstants.connectTimeout),
|
||||
receiveTimeout: Duration(milliseconds: AppConstants.receiveTimeout),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
);
|
||||
|
||||
// 请求拦截器
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
// 添加认证token
|
||||
final token = await _storageService.getToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
handler.next(response);
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
// 处理401错误,尝试刷新token
|
||||
if (error.response?.statusCode == 401) {
|
||||
final refreshed = await _refreshToken();
|
||||
if (refreshed) {
|
||||
// 重新发送请求
|
||||
final options = error.requestOptions;
|
||||
final token = await _storageService.getToken();
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
|
||||
try {
|
||||
final response = await _dio.fetch(options);
|
||||
handler.resolve(response);
|
||||
return;
|
||||
} catch (e) {
|
||||
// 刷新后仍然失败,清除token并跳转登录
|
||||
await _clearTokensAndRedirectToLogin();
|
||||
}
|
||||
} else {
|
||||
// 刷新失败,清除token并跳转登录
|
||||
await _clearTokensAndRedirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(error);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// 日志拦截器(仅在调试模式下)
|
||||
if (const bool.fromEnvironment('dart.vm.product') == false) {
|
||||
_dio.interceptors.add(
|
||||
LogInterceptor(
|
||||
requestHeader: true,
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
responseHeader: false,
|
||||
error: true,
|
||||
logPrint: (obj) => print(obj),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新token
|
||||
Future<bool> _refreshToken() async {
|
||||
try {
|
||||
final refreshToken = await _storageService.getRefreshToken();
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final response = await _dio.post(
|
||||
'/auth/refresh',
|
||||
data: {'refresh_token': refreshToken},
|
||||
options: Options(
|
||||
headers: {'Authorization': null}, // 移除Authorization头
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data;
|
||||
await _storageService.saveToken(data['access_token']);
|
||||
if (data['refresh_token'] != null) {
|
||||
await _storageService.saveRefreshToken(data['refresh_token']);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Token refresh failed: $e');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 清除token并跳转登录
|
||||
Future<void> _clearTokensAndRedirectToLogin() async {
|
||||
await _storageService.clearTokens();
|
||||
|
||||
// 跳转到登录页面并清除所有历史记录
|
||||
NavigationService.instance.navigateToAndClearStack(Routes.login);
|
||||
|
||||
// 显示提示信息
|
||||
NavigationService.instance.showErrorSnackBar('登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
/// GET请求
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
return await _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// POST请求
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
return await _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// PUT请求
|
||||
Future<Response<T>> put<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
return await _dio.put<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// DELETE请求
|
||||
Future<Response<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
return await _dio.delete<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// 上传文件
|
||||
Future<Response<T>> upload<T>(
|
||||
String path,
|
||||
FormData formData, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
}) async {
|
||||
return await _dio.post<T>(
|
||||
path,
|
||||
data: formData,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/// 下载文件
|
||||
Future<Response> download(
|
||||
String urlPath,
|
||||
String savePath, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) async {
|
||||
return await _dio.download(
|
||||
urlPath,
|
||||
savePath,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
77
client/lib/core/network/api_endpoints.dart
Normal file
77
client/lib/core/network/api_endpoints.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import '../config/environment.dart';
|
||||
|
||||
/// API端点配置
|
||||
class ApiEndpoints {
|
||||
// 基础URL - 从环境配置获取
|
||||
static String get baseUrl => EnvironmentConfig.baseUrl;
|
||||
|
||||
// 认证相关
|
||||
static const String login = '/auth/login';
|
||||
static const String register = '/auth/register';
|
||||
static const String logout = '/auth/logout';
|
||||
static const String refreshToken = '/auth/refresh';
|
||||
static const String forgotPassword = '/auth/forgot-password';
|
||||
static const String resetPassword = '/auth/reset-password';
|
||||
static const String changePassword = '/auth/change-password';
|
||||
static const String socialLogin = '/auth/social-login';
|
||||
static const String verifyEmail = '/auth/verify-email';
|
||||
static const String resendVerificationEmail = '/auth/resend-verification';
|
||||
|
||||
// 用户相关
|
||||
static const String userInfo = '/user/profile';
|
||||
static const String updateProfile = '/user/profile';
|
||||
static const String uploadAvatar = '/user/avatar';
|
||||
static const String checkUsername = '/user/check-username';
|
||||
static const String checkEmail = '/user/check-email';
|
||||
|
||||
// 学习相关
|
||||
static const String learningProgress = '/learning/progress';
|
||||
static const String learningStats = '/learning/stats';
|
||||
static const String dailyGoal = '/learning/daily-goal';
|
||||
|
||||
// 词汇相关
|
||||
static const String vocabulary = '/vocabulary';
|
||||
static const String vocabularyTest = '/vocabulary/test';
|
||||
static const String vocabularyProgress = '/vocabulary/progress';
|
||||
static const String wordBooks = '/vocabulary/books';
|
||||
static const String wordLists = '/vocabulary/lists';
|
||||
|
||||
// 听力相关
|
||||
static const String listening = '/listening';
|
||||
static const String listeningMaterials = '/listening/materials';
|
||||
static const String listeningRecords = '/listening/records';
|
||||
static const String listeningStats = '/listening/stats';
|
||||
|
||||
// 阅读相关
|
||||
static const String reading = '/reading';
|
||||
static const String readingMaterials = '/reading/materials';
|
||||
static const String readingRecords = '/reading/records';
|
||||
static const String readingStats = '/reading/stats';
|
||||
|
||||
// 写作相关
|
||||
static const String writing = '/writing';
|
||||
static const String writingPrompts = '/writing/prompts';
|
||||
static const String writingSubmissions = '/writing/submissions';
|
||||
static const String writingStats = '/writing/stats';
|
||||
|
||||
// 口语相关
|
||||
static const String speaking = '/speaking';
|
||||
static const String speakingScenarios = '/speaking/scenarios';
|
||||
static const String speakingRecords = '/speaking/records';
|
||||
static const String speakingStats = '/speaking/stats';
|
||||
|
||||
// AI相关
|
||||
static const String aiChat = '/ai/chat';
|
||||
static const String aiCorrection = '/ai/correction';
|
||||
static const String aiSuggestion = '/ai/suggestion';
|
||||
|
||||
// 文件上传
|
||||
static const String upload = '/upload';
|
||||
static const String uploadAudio = '/upload/audio';
|
||||
static const String uploadImage = '/upload/image';
|
||||
|
||||
// 系统相关
|
||||
static const String version = '/version';
|
||||
static const String config = '/system/config';
|
||||
static const String feedback = '/system/feedback';
|
||||
}
|
||||
154
client/lib/core/providers/app_state_provider.dart
Normal file
154
client/lib/core/providers/app_state_provider.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../shared/providers/auth_provider.dart';
|
||||
import '../../shared/providers/vocabulary_provider.dart';
|
||||
import '../../shared/services/auth_service.dart';
|
||||
import '../../shared/services/vocabulary_service.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
/// 全局应用状态管理
|
||||
class AppStateNotifier extends StateNotifier<AppState> {
|
||||
AppStateNotifier() : super(const AppState());
|
||||
|
||||
void updateTheme(ThemeMode themeMode) {
|
||||
state = state.copyWith(themeMode: themeMode);
|
||||
}
|
||||
|
||||
void updateLocale(String locale) {
|
||||
state = state.copyWith(locale: locale);
|
||||
}
|
||||
|
||||
void updateNetworkStatus(bool isOnline) {
|
||||
state = state.copyWith(isOnline: isOnline);
|
||||
}
|
||||
|
||||
void updateLoading(bool isLoading) {
|
||||
state = state.copyWith(isGlobalLoading: isLoading);
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用状态模型
|
||||
class AppState {
|
||||
final ThemeMode themeMode;
|
||||
final String locale;
|
||||
final bool isOnline;
|
||||
final bool isGlobalLoading;
|
||||
|
||||
const AppState({
|
||||
this.themeMode = ThemeMode.light,
|
||||
this.locale = 'zh_CN',
|
||||
this.isOnline = true,
|
||||
this.isGlobalLoading = false,
|
||||
});
|
||||
|
||||
AppState copyWith({
|
||||
ThemeMode? themeMode,
|
||||
String? locale,
|
||||
bool? isOnline,
|
||||
bool? isGlobalLoading,
|
||||
}) {
|
||||
return AppState(
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
locale: locale ?? this.locale,
|
||||
isOnline: isOnline ?? this.isOnline,
|
||||
isGlobalLoading: isGlobalLoading ?? this.isGlobalLoading,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局状态Provider
|
||||
final appStateProvider = StateNotifierProvider<AppStateNotifier, AppState>(
|
||||
(ref) => AppStateNotifier(),
|
||||
);
|
||||
|
||||
/// API客户端Provider
|
||||
final apiClientProvider = Provider<ApiClient>(
|
||||
(ref) => ApiClient.instance,
|
||||
);
|
||||
|
||||
/// 认证服务Provider
|
||||
final authServiceProvider = Provider<AuthService>(
|
||||
(ref) => AuthService(),
|
||||
);
|
||||
|
||||
/// 词汇服务Provider
|
||||
final vocabularyServiceProvider = Provider<VocabularyService>(
|
||||
(ref) => VocabularyService(),
|
||||
);
|
||||
|
||||
/// 认证Provider
|
||||
final authProvider = ChangeNotifierProvider<AuthProvider>(
|
||||
(ref) {
|
||||
final authService = ref.read(authServiceProvider);
|
||||
return AuthProvider()..initialize();
|
||||
},
|
||||
);
|
||||
|
||||
/// 词汇Provider
|
||||
final vocabularyProvider = ChangeNotifierProvider<VocabularyProvider>(
|
||||
(ref) {
|
||||
final vocabularyService = ref.read(vocabularyServiceProvider);
|
||||
return VocabularyProvider(vocabularyService);
|
||||
},
|
||||
);
|
||||
|
||||
/// 网络状态Provider
|
||||
final networkStatusProvider = StreamProvider<bool>(
|
||||
(ref) async* {
|
||||
// 这里可以实现网络状态监听
|
||||
yield true; // 默认在线状态
|
||||
},
|
||||
);
|
||||
|
||||
/// 缓存管理Provider
|
||||
final cacheManagerProvider = Provider<CacheManager>(
|
||||
(ref) => CacheManager(),
|
||||
);
|
||||
|
||||
/// 缓存管理器
|
||||
class CacheManager {
|
||||
final Map<String, dynamic> _cache = {};
|
||||
final Map<String, DateTime> _cacheTimestamps = {};
|
||||
final Duration _defaultCacheDuration = const Duration(minutes: 30);
|
||||
|
||||
/// 设置缓存
|
||||
void set(String key, dynamic value, {Duration? duration}) {
|
||||
_cache[key] = value;
|
||||
_cacheTimestamps[key] = DateTime.now();
|
||||
}
|
||||
|
||||
/// 获取缓存
|
||||
T? get<T>(String key, {Duration? duration}) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp == null) return null;
|
||||
|
||||
final cacheDuration = duration ?? _defaultCacheDuration;
|
||||
if (DateTime.now().difference(timestamp) > cacheDuration) {
|
||||
remove(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return _cache[key] as T?;
|
||||
}
|
||||
|
||||
/// 移除缓存
|
||||
void remove(String key) {
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
}
|
||||
|
||||
/// 清空缓存
|
||||
void clear() {
|
||||
_cache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
}
|
||||
|
||||
/// 检查缓存是否存在且有效
|
||||
bool isValid(String key, {Duration? duration}) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp == null) return false;
|
||||
|
||||
final cacheDuration = duration ?? _defaultCacheDuration;
|
||||
return DateTime.now().difference(timestamp) <= cacheDuration;
|
||||
}
|
||||
}
|
||||
85
client/lib/core/providers/providers.dart
Normal file
85
client/lib/core/providers/providers.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'app_state_provider.dart';
|
||||
import '../../shared/providers/network_provider.dart';
|
||||
import '../../shared/providers/error_provider.dart';
|
||||
import '../../features/auth/providers/auth_provider.dart' as auth;
|
||||
import '../../features/vocabulary/providers/vocabulary_provider.dart' as vocab;
|
||||
import '../../features/comprehensive_test/providers/test_riverpod_provider.dart' as test;
|
||||
|
||||
/// 全局Provider配置
|
||||
class GlobalProviders {
|
||||
static final List<Override> overrides = [
|
||||
// 这里可以添加测试时的Provider覆盖
|
||||
];
|
||||
|
||||
static final List<ProviderObserver> observers = [
|
||||
ProviderLogger(),
|
||||
];
|
||||
|
||||
/// 获取所有核心Provider
|
||||
static List<ProviderBase> get coreProviders => [
|
||||
// 应用状态
|
||||
appStateProvider,
|
||||
networkProvider,
|
||||
errorProvider,
|
||||
|
||||
// 认证相关
|
||||
auth.authProvider,
|
||||
|
||||
// 词汇相关
|
||||
vocab.vocabularyProvider,
|
||||
|
||||
// 综合测试相关
|
||||
test.testProvider,
|
||||
];
|
||||
|
||||
/// 预加载Provider
|
||||
static Future<void> preloadProviders(ProviderContainer container) async {
|
||||
// 预加载网络状态
|
||||
container.read(networkProvider.notifier).refreshNetworkStatus();
|
||||
|
||||
// 认证状态会在AuthNotifier构造时自动检查
|
||||
// 这里只需要读取provider来触发初始化
|
||||
container.read(auth.authProvider);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider状态监听器
|
||||
class ProviderLogger extends ProviderObserver {
|
||||
@override
|
||||
void didUpdateProvider(
|
||||
ProviderBase provider,
|
||||
Object? previousValue,
|
||||
Object? newValue,
|
||||
ProviderContainer container,
|
||||
) {
|
||||
print('Provider ${provider.name ?? provider.runtimeType} updated: $newValue');
|
||||
}
|
||||
|
||||
@override
|
||||
void didAddProvider(
|
||||
ProviderBase provider,
|
||||
Object? value,
|
||||
ProviderContainer container,
|
||||
) {
|
||||
print('Provider ${provider.name ?? provider.runtimeType} added: $value');
|
||||
}
|
||||
|
||||
@override
|
||||
void didDisposeProvider(
|
||||
ProviderBase provider,
|
||||
ProviderContainer container,
|
||||
) {
|
||||
print('Provider ${provider.name ?? provider.runtimeType} disposed');
|
||||
}
|
||||
|
||||
@override
|
||||
void providerDidFail(
|
||||
ProviderBase provider,
|
||||
Object error,
|
||||
StackTrace stackTrace,
|
||||
ProviderContainer container,
|
||||
) {
|
||||
print('Provider ${provider.name ?? provider.runtimeType} failed: $error');
|
||||
}
|
||||
}
|
||||
496
client/lib/core/routes/app_routes.dart
Normal file
496
client/lib/core/routes/app_routes.dart
Normal file
@@ -0,0 +1,496 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../features/auth/screens/splash_screen.dart';
|
||||
import '../../features/auth/screens/login_screen.dart';
|
||||
import '../../features/auth/screens/register_screen.dart';
|
||||
import '../../features/auth/screens/forgot_password_screen.dart';
|
||||
import '../../features/main/screens/main_app_screen.dart';
|
||||
import '../../features/learning/screens/learning_home_screen.dart';
|
||||
import '../../features/vocabulary/screens/vocabulary_home_screen.dart';
|
||||
import '../../features/vocabulary/screens/vocabulary_category_screen.dart';
|
||||
|
||||
import '../../features/vocabulary/screens/vocabulary_book_screen.dart';
|
||||
import '../../features/vocabulary/screens/word_learning_screen.dart';
|
||||
import '../../features/vocabulary/screens/smart_review_screen.dart';
|
||||
import '../../features/vocabulary/screens/vocabulary_test_screen.dart';
|
||||
import '../../features/vocabulary/screens/daily_words_screen.dart';
|
||||
import '../../features/vocabulary/screens/ai_recommendation_screen.dart';
|
||||
import '../../features/vocabulary/screens/word_book_screen.dart';
|
||||
import '../../features/vocabulary/screens/study_plan_screen.dart';
|
||||
import '../../features/vocabulary/models/word_model.dart';
|
||||
import '../../features/vocabulary/models/vocabulary_book_model.dart';
|
||||
import '../../features/vocabulary/models/review_models.dart';
|
||||
import '../../features/listening/screens/listening_home_screen.dart';
|
||||
import '../../features/listening/screens/listening_category_screen.dart';
|
||||
import '../../features/listening/screens/listening_exercise_detail_screen.dart';
|
||||
import '../../features/listening/screens/listening_difficulty_screen.dart';
|
||||
import '../../features/listening/screens/listening_stats_screen.dart';
|
||||
import '../../features/listening/models/listening_exercise_model.dart';
|
||||
// 移除静态数据依赖
|
||||
import '../../features/reading/screens/reading_home_screen.dart';
|
||||
import '../../features/writing/screens/writing_home_screen.dart';
|
||||
import '../../features/speaking/screens/speaking_home_screen.dart';
|
||||
import '../../features/comprehensive_test/screens/comprehensive_test_screen.dart';
|
||||
import '../../features/profile/screens/profile_home_screen.dart';
|
||||
import '../../features/profile/screens/profile_edit_screen.dart';
|
||||
import '../../features/profile/screens/settings_screen.dart';
|
||||
import '../../features/profile/screens/help_feedback_screen.dart';
|
||||
import '../../features/ai/pages/ai_main_page.dart';
|
||||
import '../../features/ai/pages/ai_writing_page.dart';
|
||||
import '../../features/ai/pages/ai_speaking_page.dart';
|
||||
import '../../features/home/screens/learning_stats_detail_screen.dart';
|
||||
import '../../features/notification/screens/notification_list_screen.dart';
|
||||
import '../widgets/not_found_screen.dart';
|
||||
|
||||
// 学习模式枚举
|
||||
enum LearningMode {
|
||||
normal,
|
||||
review,
|
||||
test
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// 路由名称常量
|
||||
class Routes {
|
||||
static const String splash = '/splash';
|
||||
static const String login = '/login';
|
||||
static const String register = '/register';
|
||||
static const String forgotPassword = '/forgot-password';
|
||||
static const String home = '/home';
|
||||
static const String learning = '/learning';
|
||||
static const String profile = '/profile';
|
||||
static const String editProfile = '/edit-profile';
|
||||
static const String settings = '/settings';
|
||||
static const String helpFeedback = '/help-feedback';
|
||||
static const String vocabularyHome = '/vocabulary';
|
||||
static const String vocabularyCategory = '/vocabulary/category';
|
||||
static const String vocabularyList = '/vocabulary/list';
|
||||
static const String vocabularyBook = '/vocabulary/book';
|
||||
static const String wordDetail = '/vocabulary/word';
|
||||
static const String vocabularyTest = '/vocabulary/test';
|
||||
static const String wordLearning = '/vocabulary/learning';
|
||||
static const String smartReview = '/vocabulary/smart-review';
|
||||
static const String dailyWords = '/vocabulary/daily-words';
|
||||
static const String aiRecommendation = '/vocabulary/ai-recommendation';
|
||||
static const String wordBook = '/vocabulary/word-book';
|
||||
static const String studyPlan = '/vocabulary/study-plan';
|
||||
static const String listeningHome = '/listening';
|
||||
static const String listeningExercise = '/listening/exercise';
|
||||
static const String listeningCategory = '/listening/category';
|
||||
static const String listeningExerciseDetail = '/listening/exercise-detail';
|
||||
static const String listeningDifficulty = '/listening/difficulty';
|
||||
static const String listeningStats = '/listening/stats';
|
||||
static const String readingHome = '/reading';
|
||||
static const String readingExercise = '/reading/exercise';
|
||||
static const String writingHome = '/writing';
|
||||
static const String writingExercise = '/writing/exercise';
|
||||
static const String speakingHome = '/speaking';
|
||||
static const String speakingExercise = '/speaking/exercise';
|
||||
static const String comprehensiveTest = '/comprehensive-test';
|
||||
static const String ai = '/ai';
|
||||
static const String aiWriting = '/ai/writing';
|
||||
static const String aiSpeaking = '/ai/speaking';
|
||||
static const String learningStatsDetail = '/learning-stats-detail';
|
||||
static const String notifications = '/notifications';
|
||||
}
|
||||
|
||||
/// 应用路由配置
|
||||
class AppRoutes {
|
||||
/// 路由映射表
|
||||
static final Map<String, WidgetBuilder> _routes = {
|
||||
Routes.splash: (context) => const SplashScreen(),
|
||||
Routes.login: (context) => const LoginScreen(),
|
||||
Routes.register: (context) => const RegisterScreen(),
|
||||
Routes.forgotPassword: (context) => const ForgotPasswordScreen(),
|
||||
Routes.home: (context) => const MainAppScreen(),
|
||||
Routes.learning: (context) => const LearningHomeScreen(),
|
||||
Routes.vocabularyHome: (context) => const VocabularyHomeScreen(),
|
||||
// TODO: 这些路由需要参数,暂时注释掉,后续通过onGenerateRoute处理
|
||||
// Routes.vocabularyList: (context) => const VocabularyBookScreen(),
|
||||
// Routes.wordDetail: (context) => const WordLearningScreen(),
|
||||
// Routes.vocabularyTest: (context) => const VocabularyTestScreen(),
|
||||
// Routes.wordLearning: (context) => const SmartReviewScreen(),
|
||||
Routes.dailyWords: (context) => const DailyWordsScreen(),
|
||||
Routes.aiRecommendation: (context) => const AIRecommendationScreen(),
|
||||
Routes.wordBook: (context) => const WordBookScreen(),
|
||||
Routes.studyPlan: (context) => const StudyPlanScreen(),
|
||||
Routes.listeningHome: (context) => const ListeningHomeScreen(),
|
||||
Routes.listeningDifficulty: (context) => const ListeningDifficultyScreen(),
|
||||
Routes.listeningStats: (context) => const ListeningStatsScreen(),
|
||||
Routes.readingHome: (context) => const ReadingHomeScreen(),
|
||||
Routes.writingHome: (context) => const WritingHomeScreen(),
|
||||
Routes.speakingHome: (context) => const SpeakingHomeScreen(),
|
||||
Routes.comprehensiveTest: (context) => const ComprehensiveTestScreen(),
|
||||
Routes.profile: (context) => const ProfileHomeScreen(),
|
||||
Routes.editProfile: (context) => const ProfileEditScreen(),
|
||||
Routes.settings: (context) => const SettingsScreen(),
|
||||
Routes.helpFeedback: (context) => const HelpFeedbackScreen(),
|
||||
Routes.ai: (context) => const AIMainPage(),
|
||||
Routes.aiWriting: (context) => const AIWritingPage(),
|
||||
Routes.aiSpeaking: (context) => const AISpeakingPage(),
|
||||
Routes.learningStatsDetail: (context) => const LearningStatsDetailScreen(),
|
||||
Routes.notifications: (context) => const NotificationListScreen(),
|
||||
// TODO: 添加其他页面路由
|
||||
};
|
||||
|
||||
/// 获取路由映射表
|
||||
static Map<String, WidgetBuilder> get routes => _routes;
|
||||
|
||||
/// 路由生成器
|
||||
static Route<dynamic>? onGenerateRoute(RouteSettings settings) {
|
||||
final String routeName = settings.name ?? '';
|
||||
final arguments = settings.arguments;
|
||||
|
||||
// 处理带参数的词汇学习路由
|
||||
switch (routeName) {
|
||||
case Routes.vocabularyCategory:
|
||||
if (arguments is Map<String, dynamic>) {
|
||||
final category = arguments['category'];
|
||||
if (category != null) {
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => VocabularyCategoryScreen(category: category),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Routes.vocabularyList:
|
||||
if (arguments is Map<String, dynamic>) {
|
||||
final vocabularyBook = arguments['vocabularyBook'];
|
||||
if (vocabularyBook != null) {
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => VocabularyBookScreen(vocabularyBook: vocabularyBook),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Routes.wordLearning:
|
||||
if (arguments is Map<String, dynamic>) {
|
||||
final vocabularyBook = arguments['vocabularyBook'];
|
||||
final specificWords = arguments['specificWords'];
|
||||
final mode = arguments['mode'];
|
||||
if (vocabularyBook != null) {
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => WordLearningScreen(
|
||||
vocabularyBook: vocabularyBook,
|
||||
specificWords: specificWords,
|
||||
mode: mode ?? LearningMode.normal,
|
||||
),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Routes.vocabularyTest:
|
||||
// 词汇测试路由,支持带参数和不带参数的情况
|
||||
if (arguments is Map<String, dynamic>) {
|
||||
final vocabularyBook = arguments['vocabularyBook'];
|
||||
final testType = arguments['testType'];
|
||||
final questionCount = arguments['questionCount'];
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => VocabularyTestScreen(
|
||||
vocabularyBook: vocabularyBook,
|
||||
testType: testType ?? TestType.vocabularyLevel,
|
||||
questionCount: questionCount ?? 20,
|
||||
),
|
||||
settings: settings,
|
||||
);
|
||||
} else {
|
||||
// 没有参数时,使用默认设置
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => const VocabularyTestScreen(
|
||||
testType: TestType.vocabularyLevel,
|
||||
questionCount: 20,
|
||||
),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case Routes.wordDetail:
|
||||
if (arguments is Map<String, dynamic>) {
|
||||
final vocabularyBook = arguments['vocabularyBook'];
|
||||
final reviewMode = arguments['reviewMode'];
|
||||
final dailyTarget = arguments['dailyTarget'];
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => SmartReviewScreen(
|
||||
vocabularyBook: vocabularyBook,
|
||||
reviewMode: reviewMode ?? ReviewMode.adaptive,
|
||||
dailyTarget: dailyTarget ?? 20,
|
||||
),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case Routes.smartReview:
|
||||
// 智能复习路由,支持带参数和不带参数的情况
|
||||
if (arguments is Map<String, dynamic>) {
|
||||
final vocabularyBook = arguments['vocabularyBook'];
|
||||
final reviewMode = arguments['reviewMode'];
|
||||
final dailyTarget = arguments['dailyTarget'];
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => SmartReviewScreen(
|
||||
vocabularyBook: vocabularyBook,
|
||||
reviewMode: reviewMode ?? ReviewMode.adaptive,
|
||||
dailyTarget: dailyTarget ?? 20,
|
||||
),
|
||||
settings: settings,
|
||||
);
|
||||
} else {
|
||||
// 没有参数时,使用默认设置
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => const SmartReviewScreen(
|
||||
reviewMode: ReviewMode.adaptive,
|
||||
dailyTarget: 20,
|
||||
),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
// 听力相关路由
|
||||
case Routes.listeningCategory:
|
||||
if (arguments is Map<String, dynamic>) {
|
||||
final type = arguments['type'] as ListeningExerciseType;
|
||||
final title = arguments['title'] as String;
|
||||
final category = ListeningCategory(
|
||||
id: type.toString(),
|
||||
name: title,
|
||||
description: '${title}练习材料',
|
||||
icon: Icons.headphones,
|
||||
exerciseCount: 0,
|
||||
type: type,
|
||||
);
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => ListeningCategoryScreen(
|
||||
category: category,
|
||||
),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case Routes.listeningExerciseDetail:
|
||||
if (arguments is Map<String, dynamic>) {
|
||||
final exerciseId = arguments['exerciseId'];
|
||||
if (exerciseId != null) {
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => ListeningExerciseDetailScreen(
|
||||
exerciseId: exerciseId,
|
||||
),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 默认路由处理
|
||||
final WidgetBuilder? builder = _routes[routeName];
|
||||
if (builder != null) {
|
||||
return MaterialPageRoute(
|
||||
builder: builder,
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
|
||||
// 未找到路由时的处理
|
||||
return MaterialPageRoute(
|
||||
builder: (context) => const NotFoundScreen(),
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
|
||||
/// 路由守卫 - 检查是否需要认证
|
||||
static bool requiresAuth(String routeName) {
|
||||
const publicRoutes = [
|
||||
Routes.splash,
|
||||
Routes.login,
|
||||
Routes.register,
|
||||
Routes.forgotPassword,
|
||||
];
|
||||
|
||||
return !publicRoutes.contains(routeName);
|
||||
}
|
||||
|
||||
/// 获取初始路由
|
||||
static String getInitialRoute(bool isLoggedIn) {
|
||||
return isLoggedIn ? Routes.home : Routes.splash;
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动页面
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeApp();
|
||||
}
|
||||
|
||||
Future<void> _initializeApp() async {
|
||||
// 初始化应用配置
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
if (mounted) {
|
||||
// 导航到登录页面,让用户进行认证
|
||||
Navigator.of(context).pushReplacementNamed(Routes.login);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// TODO: 添加应用Logo
|
||||
Icon(
|
||||
Icons.school,
|
||||
size: 100,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'AI英语学习',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'智能化英语学习平台',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
const CircularProgressIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 404页面
|
||||
class NotFoundScreen extends StatelessWidget {
|
||||
const NotFoundScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('页面未找到'),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 100,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'404',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'抱歉,您访问的页面不存在',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
Routes.home,
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
child: const Text('返回首页'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由导航辅助类
|
||||
class AppNavigator {
|
||||
/// 导航到指定页面
|
||||
static Future<T?> push<T extends Object?>(
|
||||
BuildContext context,
|
||||
String routeName, {
|
||||
Object? arguments,
|
||||
}) {
|
||||
return Navigator.of(context).pushNamed<T>(
|
||||
routeName,
|
||||
arguments: arguments,
|
||||
);
|
||||
}
|
||||
|
||||
/// 替换当前页面
|
||||
static Future<T?> pushReplacement<T extends Object?, TO extends Object?>(
|
||||
BuildContext context,
|
||||
String routeName, {
|
||||
Object? arguments,
|
||||
TO? result,
|
||||
}) {
|
||||
return Navigator.of(context).pushReplacementNamed<T, TO>(
|
||||
routeName,
|
||||
arguments: arguments,
|
||||
result: result,
|
||||
);
|
||||
}
|
||||
|
||||
/// 清空栈并导航到指定页面
|
||||
static Future<T?> pushAndRemoveUntil<T extends Object?>(
|
||||
BuildContext context,
|
||||
String routeName, {
|
||||
Object? arguments,
|
||||
bool Function(Route<dynamic>)? predicate,
|
||||
}) {
|
||||
return Navigator.of(context).pushNamedAndRemoveUntil<T>(
|
||||
routeName,
|
||||
predicate ?? (route) => false,
|
||||
arguments: arguments,
|
||||
);
|
||||
}
|
||||
|
||||
/// 返回上一页
|
||||
static void pop<T extends Object?>(BuildContext context, [T? result]) {
|
||||
Navigator.of(context).pop<T>(result);
|
||||
}
|
||||
|
||||
/// 返回到指定页面
|
||||
static void popUntil(BuildContext context, String routeName) {
|
||||
Navigator.of(context).popUntil(ModalRoute.withName(routeName));
|
||||
}
|
||||
|
||||
/// 检查是否可以返回
|
||||
static bool canPop(BuildContext context) {
|
||||
return Navigator.of(context).canPop();
|
||||
}
|
||||
}
|
||||
284
client/lib/core/services/api_service.dart
Normal file
284
client/lib/core/services/api_service.dart
Normal file
@@ -0,0 +1,284 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'storage_service.dart';
|
||||
import '../config/environment.dart';
|
||||
|
||||
class ApiResponse {
|
||||
final dynamic data;
|
||||
final int statusCode;
|
||||
final String? message;
|
||||
|
||||
ApiResponse({
|
||||
required this.data,
|
||||
required this.statusCode,
|
||||
this.message,
|
||||
});
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
late final Dio _dio;
|
||||
final StorageService _storageService;
|
||||
|
||||
ApiService({required StorageService storageService})
|
||||
: _storageService = storageService {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: EnvironmentConfig.baseUrl,
|
||||
connectTimeout: Duration(milliseconds: EnvironmentConfig.connectTimeout),
|
||||
receiveTimeout: Duration(milliseconds: EnvironmentConfig.receiveTimeout),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
));
|
||||
|
||||
_setupInterceptors();
|
||||
}
|
||||
|
||||
void _setupInterceptors() {
|
||||
// 请求拦截器
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
// 添加认证token
|
||||
final token = await _storageService.getToken();
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('API Request: ${options.method} ${options.uri}');
|
||||
print('Headers: ${options.headers}');
|
||||
if (options.data != null) {
|
||||
print('Data: ${options.data}');
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
if (kDebugMode) {
|
||||
print('API Response: ${response.statusCode} ${response.requestOptions.uri}');
|
||||
}
|
||||
handler.next(response);
|
||||
},
|
||||
onError: (error, handler) {
|
||||
if (kDebugMode) {
|
||||
print('API Error: ${error.message}');
|
||||
print('Response: ${error.response?.data}');
|
||||
}
|
||||
handler.next(error);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// GET请求
|
||||
Future<ApiResponse> get(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParams,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
path,
|
||||
queryParameters: queryParams,
|
||||
options: options,
|
||||
);
|
||||
return ApiResponse(
|
||||
data: response.data,
|
||||
statusCode: response.statusCode ?? 200,
|
||||
message: response.statusMessage,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// POST请求
|
||||
Future<ApiResponse> post(
|
||||
String path,
|
||||
dynamic data, {
|
||||
Map<String, dynamic>? queryParams,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParams,
|
||||
options: options,
|
||||
);
|
||||
return ApiResponse(
|
||||
data: response.data,
|
||||
statusCode: response.statusCode ?? 200,
|
||||
message: response.statusMessage,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT请求
|
||||
Future<ApiResponse> put(
|
||||
String path,
|
||||
dynamic data, {
|
||||
Map<String, dynamic>? queryParams,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.put(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParams,
|
||||
options: options,
|
||||
);
|
||||
return ApiResponse(
|
||||
data: response.data,
|
||||
statusCode: response.statusCode ?? 200,
|
||||
message: response.statusMessage,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH请求
|
||||
Future<ApiResponse> patch(
|
||||
String path,
|
||||
dynamic data, {
|
||||
Map<String, dynamic>? queryParams,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.patch(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParams,
|
||||
options: options,
|
||||
);
|
||||
return ApiResponse(
|
||||
data: response.data,
|
||||
statusCode: response.statusCode ?? 200,
|
||||
message: response.statusMessage,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE请求
|
||||
Future<ApiResponse> delete(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParams,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.delete(
|
||||
path,
|
||||
queryParameters: queryParams,
|
||||
options: options,
|
||||
);
|
||||
return ApiResponse(
|
||||
data: response.data,
|
||||
statusCode: response.statusCode ?? 200,
|
||||
message: response.statusMessage,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
Future<ApiResponse> uploadFile(
|
||||
String path,
|
||||
String filePath, {
|
||||
String? fileName,
|
||||
Map<String, dynamic>? data,
|
||||
ProgressCallback? onSendProgress,
|
||||
}) async {
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(
|
||||
filePath,
|
||||
filename: fileName,
|
||||
),
|
||||
...?data,
|
||||
});
|
||||
|
||||
final response = await _dio.post(
|
||||
path,
|
||||
data: formData,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
),
|
||||
onSendProgress: onSendProgress,
|
||||
);
|
||||
|
||||
return ApiResponse(
|
||||
data: response.data,
|
||||
statusCode: response.statusCode ?? 200,
|
||||
message: response.statusMessage,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
Future<void> downloadFile(
|
||||
String url,
|
||||
String savePath, {
|
||||
ProgressCallback? onReceiveProgress,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
try {
|
||||
await _dio.download(
|
||||
url,
|
||||
savePath,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 错误处理
|
||||
Exception _handleError(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return Exception('网络连接超时,请检查网络设置');
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
final message = error.response?.data?['message'] ?? '请求失败';
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return Exception('请求参数错误: $message');
|
||||
case 401:
|
||||
return Exception('认证失败,请重新登录');
|
||||
case 403:
|
||||
return Exception('权限不足: $message');
|
||||
case 404:
|
||||
return Exception('请求的资源不存在');
|
||||
case 500:
|
||||
return Exception('服务器内部错误,请稍后重试');
|
||||
default:
|
||||
return Exception('请求失败($statusCode): $message');
|
||||
}
|
||||
case DioExceptionType.cancel:
|
||||
return Exception('请求已取消');
|
||||
case DioExceptionType.connectionError:
|
||||
return Exception('网络连接失败,请检查网络设置');
|
||||
case DioExceptionType.unknown:
|
||||
default:
|
||||
return Exception('未知错误: ${error.message}');
|
||||
}
|
||||
}
|
||||
|
||||
// 取消所有请求
|
||||
void cancelRequests() {
|
||||
_dio.close();
|
||||
}
|
||||
}
|
||||
378
client/lib/core/services/audio_service.dart
Normal file
378
client/lib/core/services/audio_service.dart
Normal file
@@ -0,0 +1,378 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
// 音频录制状态
|
||||
enum RecordingState {
|
||||
idle,
|
||||
recording,
|
||||
paused,
|
||||
stopped,
|
||||
}
|
||||
|
||||
// 音频播放状态
|
||||
enum PlaybackState {
|
||||
idle,
|
||||
playing,
|
||||
paused,
|
||||
stopped,
|
||||
}
|
||||
|
||||
class AudioService {
|
||||
// 录制相关
|
||||
RecordingState _recordingState = RecordingState.idle;
|
||||
String? _currentRecordingPath;
|
||||
DateTime? _recordingStartTime;
|
||||
|
||||
// 播放相关
|
||||
PlaybackState _playbackState = PlaybackState.idle;
|
||||
String? _currentPlayingPath;
|
||||
|
||||
// 回调函数
|
||||
Function(RecordingState)? onRecordingStateChanged;
|
||||
Function(PlaybackState)? onPlaybackStateChanged;
|
||||
Function(Duration)? onRecordingProgress;
|
||||
Function(Duration)? onPlaybackProgress;
|
||||
Function(String)? onRecordingComplete;
|
||||
Function()? onPlaybackComplete;
|
||||
|
||||
// Getters
|
||||
RecordingState get recordingState => _recordingState;
|
||||
PlaybackState get playbackState => _playbackState;
|
||||
String? get currentRecordingPath => _currentRecordingPath;
|
||||
String? get currentPlayingPath => _currentPlayingPath;
|
||||
bool get isRecording => _recordingState == RecordingState.recording;
|
||||
bool get isPlaying => _playbackState == PlaybackState.playing;
|
||||
|
||||
// 初始化音频服务
|
||||
Future<void> initialize() async {
|
||||
// 请求麦克风权限
|
||||
await _requestPermissions();
|
||||
}
|
||||
|
||||
// 请求权限
|
||||
Future<bool> _requestPermissions() async {
|
||||
// Web平台不支持某些权限,需要特殊处理
|
||||
if (kIsWeb) {
|
||||
// Web平台只需要麦克风权限,且通过浏览器API处理
|
||||
if (kDebugMode) {
|
||||
print('Web平台:跳过权限请求');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
final microphoneStatus = await Permission.microphone.request();
|
||||
|
||||
if (microphoneStatus != PermissionStatus.granted) {
|
||||
throw Exception('需要麦克风权限才能录音');
|
||||
}
|
||||
|
||||
// 存储权限在某些平台可能不需要
|
||||
try {
|
||||
final storageStatus = await Permission.storage.request();
|
||||
if (storageStatus != PermissionStatus.granted) {
|
||||
if (kDebugMode) {
|
||||
print('存储权限未授予,但继续执行');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 某些平台不支持存储权限,忽略错误
|
||||
if (kDebugMode) {
|
||||
print('存储权限请求失败(可能不支持): $e');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('权限请求失败: $e');
|
||||
}
|
||||
// 在某些平台上,权限请求可能失败,但仍然可以继续
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 开始录音
|
||||
Future<void> startRecording({String? fileName}) async {
|
||||
try {
|
||||
if (_recordingState == RecordingState.recording) {
|
||||
throw Exception('已经在录音中');
|
||||
}
|
||||
|
||||
await _requestPermissions();
|
||||
|
||||
// 生成录音文件路径
|
||||
if (kIsWeb) {
|
||||
// Web平台使用内存存储或IndexedDB
|
||||
fileName ??= 'recording_${DateTime.now().millisecondsSinceEpoch}.webm';
|
||||
_currentRecordingPath = '/recordings/$fileName';
|
||||
} else {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final recordingsDir = Directory('${directory.path}/recordings');
|
||||
if (!await recordingsDir.exists()) {
|
||||
await recordingsDir.create(recursive: true);
|
||||
}
|
||||
|
||||
fileName ??= 'recording_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||
_currentRecordingPath = '${recordingsDir.path}/$fileName';
|
||||
}
|
||||
|
||||
// 这里应该使用实际的录音插件,比如 record 或 flutter_sound
|
||||
// 由于没有实际的录音插件,这里只是模拟
|
||||
_recordingStartTime = DateTime.now();
|
||||
_setRecordingState(RecordingState.recording);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('开始录音: $_currentRecordingPath');
|
||||
}
|
||||
|
||||
// 模拟录音进度更新
|
||||
_startRecordingProgressTimer();
|
||||
|
||||
} catch (e) {
|
||||
throw Exception('开始录音失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
// 停止录音
|
||||
Future<String?> stopRecording() async {
|
||||
try {
|
||||
if (_recordingState != RecordingState.recording) {
|
||||
throw Exception('当前没有在录音');
|
||||
}
|
||||
|
||||
// 这里应该调用实际录音插件的停止方法
|
||||
_setRecordingState(RecordingState.stopped);
|
||||
|
||||
final recordingPath = _currentRecordingPath;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('录音完成: $recordingPath');
|
||||
}
|
||||
|
||||
// 通知录音完成
|
||||
if (recordingPath != null && onRecordingComplete != null) {
|
||||
onRecordingComplete!(recordingPath);
|
||||
}
|
||||
|
||||
return recordingPath;
|
||||
} catch (e) {
|
||||
throw Exception('停止录音失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停录音
|
||||
Future<void> pauseRecording() async {
|
||||
try {
|
||||
if (_recordingState != RecordingState.recording) {
|
||||
throw Exception('当前没有在录音');
|
||||
}
|
||||
|
||||
// 这里应该调用实际录音插件的暂停方法
|
||||
_setRecordingState(RecordingState.paused);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('录音已暂停');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('暂停录音失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复录音
|
||||
Future<void> resumeRecording() async {
|
||||
try {
|
||||
if (_recordingState != RecordingState.paused) {
|
||||
throw Exception('录音没有暂停');
|
||||
}
|
||||
|
||||
// 这里应该调用实际录音插件的恢复方法
|
||||
_setRecordingState(RecordingState.recording);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('录音已恢复');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('恢复录音失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
Future<void> playAudio(String audioPath) async {
|
||||
try {
|
||||
if (_playbackState == PlaybackState.playing) {
|
||||
await stopPlayback();
|
||||
}
|
||||
|
||||
_currentPlayingPath = audioPath;
|
||||
|
||||
// 这里应该使用实际的音频播放插件,比如 audioplayers 或 just_audio
|
||||
// 由于没有实际的播放插件,这里只是模拟
|
||||
_setPlaybackState(PlaybackState.playing);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('开始播放: $audioPath');
|
||||
}
|
||||
|
||||
// 模拟播放进度和完成
|
||||
_startPlaybackProgressTimer();
|
||||
|
||||
} catch (e) {
|
||||
throw Exception('播放音频失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停播放
|
||||
Future<void> pausePlayback() async {
|
||||
try {
|
||||
if (_playbackState != PlaybackState.playing) {
|
||||
throw Exception('当前没有在播放');
|
||||
}
|
||||
|
||||
// 这里应该调用实际播放插件的暂停方法
|
||||
_setPlaybackState(PlaybackState.paused);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('播放已暂停');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('暂停播放失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复播放
|
||||
Future<void> resumePlayback() async {
|
||||
try {
|
||||
if (_playbackState != PlaybackState.paused) {
|
||||
throw Exception('播放没有暂停');
|
||||
}
|
||||
|
||||
// 这里应该调用实际播放插件的恢复方法
|
||||
_setPlaybackState(PlaybackState.playing);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('播放已恢复');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('恢复播放失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
// 停止播放
|
||||
Future<void> stopPlayback() async {
|
||||
try {
|
||||
// 这里应该调用实际播放插件的停止方法
|
||||
_setPlaybackState(PlaybackState.stopped);
|
||||
_currentPlayingPath = null;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('播放已停止');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('停止播放失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
// 获取音频文件时长
|
||||
Future<Duration?> getAudioDuration(String audioPath) async {
|
||||
try {
|
||||
// 这里应该使用实际的音频插件获取时长
|
||||
// 模拟返回时长
|
||||
return const Duration(seconds: 30);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('获取音频时长失败: ${e.toString()}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除录音文件
|
||||
Future<bool> deleteRecording(String filePath) async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('删除录音文件失败: ${e.toString()}');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有录音文件
|
||||
Future<List<String>> getAllRecordings() async {
|
||||
try {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final recordingsDir = Directory('${directory.path}/recordings');
|
||||
|
||||
if (!await recordingsDir.exists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final files = await recordingsDir.list().toList();
|
||||
return files
|
||||
.where((file) => file is File && file.path.endsWith('.m4a'))
|
||||
.map((file) => file.path)
|
||||
.toList();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('获取录音文件列表失败: ${e.toString()}');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 私有方法:设置录音状态
|
||||
void _setRecordingState(RecordingState state) {
|
||||
_recordingState = state;
|
||||
onRecordingStateChanged?.call(state);
|
||||
}
|
||||
|
||||
// 私有方法:设置播放状态
|
||||
void _setPlaybackState(PlaybackState state) {
|
||||
_playbackState = state;
|
||||
onPlaybackStateChanged?.call(state);
|
||||
}
|
||||
|
||||
// 私有方法:录音进度计时器
|
||||
void _startRecordingProgressTimer() {
|
||||
// 这里应该实现实际的进度更新逻辑
|
||||
// 模拟进度更新
|
||||
}
|
||||
|
||||
// 私有方法:播放进度计时器
|
||||
void _startPlaybackProgressTimer() {
|
||||
// 这里应该实现实际的播放进度更新逻辑
|
||||
// 模拟播放完成
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
_setPlaybackState(PlaybackState.stopped);
|
||||
onPlaybackComplete?.call();
|
||||
});
|
||||
}
|
||||
|
||||
// 释放资源
|
||||
void dispose() {
|
||||
// 停止所有操作
|
||||
if (_recordingState == RecordingState.recording) {
|
||||
stopRecording();
|
||||
}
|
||||
if (_playbackState == PlaybackState.playing) {
|
||||
stopPlayback();
|
||||
}
|
||||
|
||||
// 清理回调
|
||||
onRecordingStateChanged = null;
|
||||
onPlaybackStateChanged = null;
|
||||
onRecordingProgress = null;
|
||||
onPlaybackProgress = null;
|
||||
onRecordingComplete = null;
|
||||
onPlaybackComplete = null;
|
||||
}
|
||||
}
|
||||
327
client/lib/core/services/auth_service.dart
Normal file
327
client/lib/core/services/auth_service.dart
Normal file
@@ -0,0 +1,327 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../models/user_model.dart';
|
||||
import '../network/api_client.dart';
|
||||
import '../network/api_endpoints.dart';
|
||||
import '../errors/app_exception.dart';
|
||||
|
||||
/// 认证服务
|
||||
class AuthService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
AuthService(this._apiClient);
|
||||
|
||||
/// 登录
|
||||
Future<AuthResponse> login({
|
||||
required String account, // 用户名或邮箱
|
||||
required String password,
|
||||
bool rememberMe = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
ApiEndpoints.login,
|
||||
data: {
|
||||
'account': account,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
// 后端返回格式: {code, message, data: {user, access_token, refresh_token, expires_in}}
|
||||
final data = response.data['data'];
|
||||
final userInfo = data['user'];
|
||||
|
||||
return AuthResponse(
|
||||
user: User(
|
||||
id: userInfo['id'].toString(),
|
||||
username: userInfo['username'],
|
||||
email: userInfo['email'],
|
||||
avatar: userInfo['avatar'],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
token: data['access_token'],
|
||||
refreshToken: data['refresh_token'],
|
||||
expiresAt: DateTime.now().add(Duration(seconds: data['expires_in'])),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('登录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户注册
|
||||
Future<AuthResponse> register({
|
||||
required String email,
|
||||
required String password,
|
||||
required String username,
|
||||
required String nickname,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
ApiEndpoints.register,
|
||||
data: {
|
||||
'email': email,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'nickname': nickname,
|
||||
},
|
||||
);
|
||||
|
||||
// 后端返回的数据结构需要转换
|
||||
final data = response.data['data'];
|
||||
final userInfo = data['user'];
|
||||
|
||||
return AuthResponse(
|
||||
user: User(
|
||||
id: userInfo['id'].toString(),
|
||||
username: userInfo['username'],
|
||||
email: userInfo['email'],
|
||||
avatar: userInfo['avatar'],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
token: data['access_token'],
|
||||
refreshToken: data['refresh_token'],
|
||||
expiresAt: DateTime.now().add(Duration(seconds: data['expires_in'])),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('注册失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 第三方登录
|
||||
Future<AuthResponse> socialLogin({
|
||||
required String provider,
|
||||
required String accessToken,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
ApiEndpoints.socialLogin,
|
||||
data: {
|
||||
'provider': provider,
|
||||
'access_token': accessToken,
|
||||
},
|
||||
);
|
||||
|
||||
return AuthResponse.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('第三方登录失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 忘记密码
|
||||
Future<void> forgotPassword(String email) async {
|
||||
try {
|
||||
await _apiClient.post(
|
||||
ApiEndpoints.forgotPassword,
|
||||
data: {'email': email},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('发送重置密码邮件失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置密码
|
||||
Future<void> resetPassword({
|
||||
required String token,
|
||||
required String newPassword,
|
||||
required String confirmPassword,
|
||||
}) async {
|
||||
try {
|
||||
await _apiClient.post(
|
||||
ApiEndpoints.resetPassword,
|
||||
data: {
|
||||
'token': token,
|
||||
'new_password': newPassword,
|
||||
'confirm_password': confirmPassword,
|
||||
},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('重置密码失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 修改密码
|
||||
Future<void> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
required String confirmPassword,
|
||||
}) async {
|
||||
try {
|
||||
await _apiClient.put(
|
||||
ApiEndpoints.changePassword,
|
||||
data: {
|
||||
'current_password': currentPassword,
|
||||
'new_password': newPassword,
|
||||
'confirm_password': confirmPassword,
|
||||
},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('修改密码失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Token
|
||||
Future<TokenRefreshResponse> refreshToken(String refreshToken) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
ApiEndpoints.refreshToken,
|
||||
data: {'refresh_token': refreshToken},
|
||||
);
|
||||
|
||||
return TokenRefreshResponse.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('刷新Token失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _apiClient.post(ApiEndpoints.logout);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('登出失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取用户信息
|
||||
Future<User> getUserInfo() async {
|
||||
try {
|
||||
final response = await _apiClient.get(ApiEndpoints.userInfo);
|
||||
return User.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('获取用户信息失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前用户信息(getUserInfo的别名)
|
||||
Future<User> getCurrentUser() async {
|
||||
return await getUserInfo();
|
||||
}
|
||||
|
||||
/// 更新用户信息
|
||||
Future<User> updateUserInfo(Map<String, dynamic> data) async {
|
||||
try {
|
||||
final response = await _apiClient.put(
|
||||
ApiEndpoints.userInfo,
|
||||
data: data,
|
||||
);
|
||||
return User.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('更新用户信息失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证邮箱
|
||||
Future<void> verifyEmail(String token) async {
|
||||
try {
|
||||
await _apiClient.post(
|
||||
ApiEndpoints.verifyEmail,
|
||||
data: {'token': token},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('验证邮箱失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新发送验证邮件
|
||||
Future<void> resendVerificationEmail() async {
|
||||
try {
|
||||
await _apiClient.post(ApiEndpoints.resendVerificationEmail);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('发送验证邮件失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查用户名是否可用
|
||||
Future<bool> checkUsernameAvailability(String username) async {
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
'${ApiEndpoints.checkUsername}?username=$username',
|
||||
);
|
||||
return response.data['available'] ?? false;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('检查用户名失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查邮箱是否可用
|
||||
Future<bool> checkEmailAvailability(String email) async {
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
'${ApiEndpoints.checkEmail}?email=$email',
|
||||
);
|
||||
return response.data['available'] ?? false;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioException(e);
|
||||
} catch (e) {
|
||||
throw AppException('检查邮箱失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理Dio异常
|
||||
AppException _handleDioException(DioException e) {
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
return NetworkException('连接超时');
|
||||
case DioExceptionType.sendTimeout:
|
||||
return NetworkException('发送超时');
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return NetworkException('接收超时');
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = e.response?.statusCode;
|
||||
final message = e.response?.data?['message'] ?? '请求失败';
|
||||
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return ValidationException(message);
|
||||
case 401:
|
||||
return AuthException('认证失败');
|
||||
case 403:
|
||||
return AuthException('权限不足');
|
||||
case 404:
|
||||
return AppException('资源不存在');
|
||||
case 422:
|
||||
return ValidationException(message);
|
||||
case 500:
|
||||
return ServerException('服务器内部错误');
|
||||
default:
|
||||
return AppException('请求失败: $message');
|
||||
}
|
||||
case DioExceptionType.cancel:
|
||||
return AppException('请求已取消');
|
||||
case DioExceptionType.connectionError:
|
||||
return NetworkException('网络连接错误');
|
||||
case DioExceptionType.badCertificate:
|
||||
return NetworkException('证书错误');
|
||||
case DioExceptionType.unknown:
|
||||
default:
|
||||
return AppException('未知错误: ${e.message}');
|
||||
}
|
||||
}
|
||||
}
|
||||
252
client/lib/core/services/cache_service.dart
Normal file
252
client/lib/core/services/cache_service.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
|
||||
/// API数据缓存服务
|
||||
class CacheService {
|
||||
static const String _cachePrefix = 'api_cache_';
|
||||
static const String _timestampPrefix = 'cache_timestamp_';
|
||||
static const Duration _defaultCacheDuration = Duration(minutes: 30);
|
||||
|
||||
/// 设置缓存
|
||||
static Future<void> setCache(
|
||||
String key,
|
||||
dynamic data, {
|
||||
Duration? duration,
|
||||
}) async {
|
||||
try {
|
||||
final cacheKey = _cachePrefix + key;
|
||||
final timestampKey = _timestampPrefix + key;
|
||||
|
||||
// 存储数据
|
||||
final jsonData = jsonEncode(data);
|
||||
await StorageService.setString(cacheKey, jsonData);
|
||||
|
||||
// 存储时间戳
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
await StorageService.setInt(timestampKey, timestamp);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Cache set for key: $key');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error setting cache for key $key: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取缓存
|
||||
static Future<T?> getCache<T>(
|
||||
String key, {
|
||||
Duration? duration,
|
||||
T Function(Map<String, dynamic>)? fromJson,
|
||||
}) async {
|
||||
try {
|
||||
final cacheKey = _cachePrefix + key;
|
||||
final timestampKey = _timestampPrefix + key;
|
||||
|
||||
// 检查缓存是否存在
|
||||
final cachedData = await StorageService.getString(cacheKey);
|
||||
final timestamp = await StorageService.getInt(timestampKey);
|
||||
|
||||
if (cachedData == null || timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查缓存是否过期
|
||||
final cacheDuration = duration ?? _defaultCacheDuration;
|
||||
final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final now = DateTime.now();
|
||||
|
||||
if (now.difference(cacheTime) > cacheDuration) {
|
||||
// 缓存过期,删除缓存
|
||||
await removeCache(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析数据
|
||||
final jsonData = jsonDecode(cachedData);
|
||||
|
||||
if (fromJson != null && jsonData is Map<String, dynamic>) {
|
||||
return fromJson(jsonData);
|
||||
}
|
||||
|
||||
return jsonData as T;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error getting cache for key $key: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取列表缓存
|
||||
static Future<List<T>?> getListCache<T>(
|
||||
String key, {
|
||||
Duration? duration,
|
||||
required T Function(Map<String, dynamic>) fromJson,
|
||||
}) async {
|
||||
try {
|
||||
final cacheKey = _cachePrefix + key;
|
||||
final timestampKey = _timestampPrefix + key;
|
||||
|
||||
// 检查缓存是否存在
|
||||
final cachedData = await StorageService.getString(cacheKey);
|
||||
final timestamp = await StorageService.getInt(timestampKey);
|
||||
|
||||
if (cachedData == null || timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查缓存是否过期
|
||||
final cacheDuration = duration ?? _defaultCacheDuration;
|
||||
final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final now = DateTime.now();
|
||||
|
||||
if (now.difference(cacheTime) > cacheDuration) {
|
||||
// 缓存过期,删除缓存
|
||||
await removeCache(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析数据
|
||||
final jsonData = jsonDecode(cachedData);
|
||||
|
||||
if (jsonData is List) {
|
||||
return jsonData
|
||||
.map((item) => fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error getting list cache for key $key: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查缓存是否有效
|
||||
static Future<bool> isCacheValid(
|
||||
String key, {
|
||||
Duration? duration,
|
||||
}) async {
|
||||
try {
|
||||
final timestampKey = _timestampPrefix + key;
|
||||
final timestamp = await StorageService.getInt(timestampKey);
|
||||
|
||||
if (timestamp == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final cacheDuration = duration ?? _defaultCacheDuration;
|
||||
final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final now = DateTime.now();
|
||||
|
||||
return now.difference(cacheTime) <= cacheDuration;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除缓存
|
||||
static Future<void> removeCache(String key) async {
|
||||
try {
|
||||
final cacheKey = _cachePrefix + key;
|
||||
final timestampKey = _timestampPrefix + key;
|
||||
|
||||
await StorageService.remove(cacheKey);
|
||||
await StorageService.remove(timestampKey);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Cache removed for key: $key');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error removing cache for key $key: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空所有缓存
|
||||
static Future<void> clearAllCache() async {
|
||||
try {
|
||||
final keys = StorageService.getKeys();
|
||||
final cacheKeys = keys.where((key) =>
|
||||
key.startsWith(_cachePrefix) || key.startsWith(_timestampPrefix));
|
||||
|
||||
for (final key in cacheKeys) {
|
||||
await StorageService.remove(key);
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('All cache cleared');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error clearing all cache: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取缓存大小信息
|
||||
static Future<Map<String, int>> getCacheInfo() async {
|
||||
try {
|
||||
final keys = StorageService.getKeys();
|
||||
final cacheKeys = keys.where((key) => key.startsWith(_cachePrefix));
|
||||
final timestampKeys = keys.where((key) => key.startsWith(_timestampPrefix));
|
||||
|
||||
int totalSize = 0;
|
||||
for (final key in cacheKeys) {
|
||||
final data = await StorageService.getString(key);
|
||||
if (data != null) {
|
||||
totalSize += data.length;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'count': cacheKeys.length,
|
||||
'size': totalSize,
|
||||
'timestamps': timestampKeys.length,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
'count': 0,
|
||||
'size': 0,
|
||||
'timestamps': 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理过期缓存
|
||||
static Future<void> cleanExpiredCache() async {
|
||||
try {
|
||||
final keys = StorageService.getKeys();
|
||||
final timestampKeys = keys.where((key) => key.startsWith(_timestampPrefix));
|
||||
|
||||
for (final timestampKey in timestampKeys) {
|
||||
final timestamp = await StorageService.getInt(timestampKey);
|
||||
if (timestamp != null) {
|
||||
final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final now = DateTime.now();
|
||||
|
||||
if (now.difference(cacheTime) > _defaultCacheDuration) {
|
||||
final cacheKey = timestampKey.replaceFirst(_timestampPrefix, _cachePrefix);
|
||||
await StorageService.remove(cacheKey);
|
||||
await StorageService.remove(timestampKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Expired cache cleaned');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error cleaning expired cache: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
288
client/lib/core/services/enhanced_api_service.dart
Normal file
288
client/lib/core/services/enhanced_api_service.dart
Normal file
@@ -0,0 +1,288 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../network/api_client.dart';
|
||||
import '../services/cache_service.dart';
|
||||
import '../models/api_response.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
|
||||
/// 增强版API服务,集成缓存功能
|
||||
class EnhancedApiService {
|
||||
final ApiClient _apiClient = ApiClient.instance;
|
||||
|
||||
/// GET请求,支持缓存
|
||||
Future<ApiResponse<T>> get<T>(
|
||||
String endpoint, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
bool useCache = true,
|
||||
Duration? cacheDuration,
|
||||
T Function(Map<String, dynamic>)? fromJson,
|
||||
}) async {
|
||||
try {
|
||||
// 生成缓存键
|
||||
final cacheKey = _generateCacheKey('GET', endpoint, queryParameters);
|
||||
|
||||
// 尝试从缓存获取数据
|
||||
if (useCache) {
|
||||
final cachedData = await CacheService.getCache<T>(
|
||||
cacheKey,
|
||||
duration: cacheDuration,
|
||||
fromJson: fromJson,
|
||||
);
|
||||
|
||||
if (cachedData != null) {
|
||||
if (kDebugMode) {
|
||||
print('Cache hit for: $endpoint');
|
||||
}
|
||||
return ApiResponse.success(
|
||||
data: cachedData,
|
||||
message: 'Data from cache',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 发起网络请求
|
||||
final response = await _apiClient.get(
|
||||
endpoint,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
// 缓存成功响应的数据
|
||||
if (useCache) {
|
||||
await CacheService.setCache(
|
||||
cacheKey,
|
||||
response.data,
|
||||
duration: cacheDuration,
|
||||
);
|
||||
}
|
||||
|
||||
return ApiResponse.success(
|
||||
data: fromJson != null ? fromJson(response.data) : response.data,
|
||||
message: 'Success',
|
||||
);
|
||||
}
|
||||
|
||||
return ApiResponse.error(
|
||||
message: 'Request failed with status: ${response.statusCode}',
|
||||
code: response.statusCode,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error in enhanced GET request: $e');
|
||||
}
|
||||
return ApiResponse.error(
|
||||
message: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST请求
|
||||
Future<ApiResponse<T>> post<T>(
|
||||
String endpoint, {
|
||||
Map<String, dynamic>? data,
|
||||
bool invalidateCache = true,
|
||||
List<String>? cacheKeysToInvalidate,
|
||||
T Function(Map<String, dynamic>)? fromJson,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
endpoint,
|
||||
data: data,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
// POST请求成功后,清除相关缓存
|
||||
if (invalidateCache) {
|
||||
await _invalidateRelatedCache(endpoint, cacheKeysToInvalidate);
|
||||
}
|
||||
|
||||
return ApiResponse.success(
|
||||
data: fromJson != null && response.data != null ? fromJson(response.data) : response.data,
|
||||
message: 'Success',
|
||||
);
|
||||
}
|
||||
|
||||
return ApiResponse.error(
|
||||
message: 'Request failed with status: ${response.statusCode}',
|
||||
code: response.statusCode,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error in enhanced POST request: $e');
|
||||
}
|
||||
return ApiResponse.error(
|
||||
message: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT请求
|
||||
Future<ApiResponse<T>> put<T>(
|
||||
String endpoint, {
|
||||
Map<String, dynamic>? data,
|
||||
bool invalidateCache = true,
|
||||
List<String>? cacheKeysToInvalidate,
|
||||
T Function(Map<String, dynamic>)? fromJson,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.put(
|
||||
endpoint,
|
||||
data: data,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// PUT请求成功后,清除相关缓存
|
||||
if (invalidateCache) {
|
||||
await _invalidateRelatedCache(endpoint, cacheKeysToInvalidate);
|
||||
}
|
||||
|
||||
return ApiResponse.success(
|
||||
data: fromJson != null && response.data != null ? fromJson(response.data) : response.data,
|
||||
message: 'Success',
|
||||
);
|
||||
}
|
||||
|
||||
return ApiResponse.error(
|
||||
message: 'Request failed with status: ${response.statusCode}',
|
||||
code: response.statusCode,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error in enhanced PUT request: $e');
|
||||
}
|
||||
return ApiResponse.error(
|
||||
message: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE请求
|
||||
Future<ApiResponse<T>> delete<T>(
|
||||
String endpoint, {
|
||||
bool invalidateCache = true,
|
||||
List<String>? cacheKeysToInvalidate,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.delete(
|
||||
endpoint,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 204) {
|
||||
// DELETE请求成功后,清除相关缓存
|
||||
if (invalidateCache) {
|
||||
await _invalidateRelatedCache(endpoint, cacheKeysToInvalidate);
|
||||
}
|
||||
|
||||
return ApiResponse.success(
|
||||
data: null,
|
||||
message: 'Success',
|
||||
);
|
||||
}
|
||||
|
||||
return ApiResponse.error(
|
||||
message: 'Request failed with status: ${response.statusCode}',
|
||||
code: response.statusCode,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error in enhanced DELETE request: $e');
|
||||
}
|
||||
return ApiResponse.error(
|
||||
message: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成缓存键
|
||||
String _generateCacheKey(
|
||||
String method,
|
||||
String endpoint,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.write(method);
|
||||
buffer.write('_');
|
||||
buffer.write(endpoint.replaceAll('/', '_'));
|
||||
|
||||
if (queryParameters != null && queryParameters.isNotEmpty) {
|
||||
final sortedKeys = queryParameters.keys.toList()..sort();
|
||||
for (final key in sortedKeys) {
|
||||
buffer.write('_${key}_${queryParameters[key]}');
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// 清除相关缓存
|
||||
Future<void> _invalidateRelatedCache(
|
||||
String endpoint,
|
||||
List<String>? specificKeys,
|
||||
) async {
|
||||
try {
|
||||
// 清除指定的缓存键
|
||||
if (specificKeys != null) {
|
||||
for (final key in specificKeys) {
|
||||
await CacheService.removeCache(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除与当前端点相关的缓存
|
||||
final endpointKey = endpoint.replaceAll('/', '_');
|
||||
final keys = StorageService.getKeys();
|
||||
final relatedKeys = keys.where((key) => key.contains(endpointKey));
|
||||
|
||||
for (final key in relatedKeys) {
|
||||
final cacheKey = key.replaceFirst('api_cache_', '');
|
||||
await CacheService.removeCache(cacheKey);
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Cache invalidated for endpoint: $endpoint');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error invalidating cache: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 预加载数据到缓存
|
||||
Future<void> preloadCache(
|
||||
String endpoint, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Duration? cacheDuration,
|
||||
}) async {
|
||||
try {
|
||||
await get(
|
||||
endpoint,
|
||||
queryParameters: queryParameters,
|
||||
useCache: true,
|
||||
cacheDuration: cacheDuration,
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Cache preloaded for: $endpoint');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error preloading cache: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取缓存信息
|
||||
Future<Map<String, int>> getCacheInfo() async {
|
||||
return await CacheService.getCacheInfo();
|
||||
}
|
||||
|
||||
/// 清理过期缓存
|
||||
Future<void> cleanExpiredCache() async {
|
||||
await CacheService.cleanExpiredCache();
|
||||
}
|
||||
|
||||
/// 清空所有缓存
|
||||
Future<void> clearAllCache() async {
|
||||
await CacheService.clearAllCache();
|
||||
}
|
||||
}
|
||||
125
client/lib/core/services/navigation_service.dart
Normal file
125
client/lib/core/services/navigation_service.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局导航服务
|
||||
/// 用于在非Widget上下文中进行页面导航
|
||||
class NavigationService {
|
||||
static final NavigationService _instance = NavigationService._internal();
|
||||
|
||||
factory NavigationService() => _instance;
|
||||
|
||||
NavigationService._internal();
|
||||
|
||||
static NavigationService get instance => _instance;
|
||||
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
/// 获取当前上下文
|
||||
BuildContext? get context => navigatorKey.currentContext;
|
||||
|
||||
/// 获取Navigator
|
||||
NavigatorState? get navigator => navigatorKey.currentState;
|
||||
|
||||
/// 导航到指定路由
|
||||
Future<T?>? navigateTo<T>(String routeName, {Object? arguments}) {
|
||||
return navigator?.pushNamed<T>(routeName, arguments: arguments);
|
||||
}
|
||||
|
||||
/// 替换当前路由
|
||||
Future<T?>? replaceWith<T extends Object?>(String routeName, {Object? arguments}) {
|
||||
return navigator?.pushReplacementNamed<T, T>(routeName, arguments: arguments);
|
||||
}
|
||||
|
||||
/// 导航到指定路由并清除所有历史
|
||||
Future<T?>? navigateToAndClearStack<T extends Object?>(String routeName, {Object? arguments}) {
|
||||
return navigator?.pushNamedAndRemoveUntil<T>(
|
||||
routeName,
|
||||
(route) => false,
|
||||
arguments: arguments,
|
||||
);
|
||||
}
|
||||
|
||||
/// 返回上一页
|
||||
void goBack<T>([T? result]) {
|
||||
if (navigator?.canPop() ?? false) {
|
||||
navigator?.pop<T>(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回到指定路由
|
||||
void popUntil(String routeName) {
|
||||
navigator?.popUntil(ModalRoute.withName(routeName));
|
||||
}
|
||||
|
||||
/// 显示SnackBar
|
||||
void showSnackBar(String message, {
|
||||
Duration duration = const Duration(seconds: 2),
|
||||
SnackBarAction? action,
|
||||
}) {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context!);
|
||||
// 清除之前的所有SnackBar
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
duration: duration,
|
||||
action: action,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示错误SnackBar
|
||||
void showErrorSnackBar(String message) {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context!);
|
||||
// 清除之前的所有SnackBar
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示成功SnackBar
|
||||
void showSuccessSnackBar(String message) {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context!);
|
||||
// 清除之前的所有SnackBar
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示对话框
|
||||
Future<T?> showDialogWidget<T>(Widget dialog) {
|
||||
return showDialog<T>(
|
||||
context: context!,
|
||||
builder: (context) => dialog,
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示底部弹窗
|
||||
Future<T?> showBottomSheetWidget<T>(Widget bottomSheet) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context!,
|
||||
builder: (context) => bottomSheet,
|
||||
);
|
||||
}
|
||||
}
|
||||
342
client/lib/core/services/storage_service.dart
Normal file
342
client/lib/core/services/storage_service.dart
Normal file
@@ -0,0 +1,342 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../utils/exceptions.dart';
|
||||
|
||||
/// 存储服务
|
||||
class StorageService {
|
||||
static StorageService? _instance;
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
StorageService._();
|
||||
|
||||
/// 获取单例实例
|
||||
static Future<StorageService> getInstance() async {
|
||||
if (_instance == null) {
|
||||
_instance = StorageService._();
|
||||
await _instance!._init();
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 获取已初始化的实例(同步方法,必须先调用getInstance)
|
||||
static StorageService get instance {
|
||||
if (_instance == null) {
|
||||
throw Exception('StorageService not initialized. Call getInstance() first.');
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 初始化
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
} catch (e) {
|
||||
throw CacheException('初始化存储服务失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 普通存储 ====================
|
||||
|
||||
/// 存储字符串
|
||||
Future<bool> setString(String key, String value) async {
|
||||
try {
|
||||
return await _prefs!.setString(key, value);
|
||||
} catch (e) {
|
||||
throw CacheException('存储字符串失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取字符串
|
||||
String? getString(String key, {String? defaultValue}) {
|
||||
try {
|
||||
return _prefs!.getString(key) ?? defaultValue;
|
||||
} catch (e) {
|
||||
throw CacheException('获取字符串失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 存储整数
|
||||
Future<bool> setInt(String key, int value) async {
|
||||
try {
|
||||
return await _prefs!.setInt(key, value);
|
||||
} catch (e) {
|
||||
throw CacheException('存储整数失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取整数
|
||||
int? getInt(String key, {int? defaultValue}) {
|
||||
try {
|
||||
return _prefs!.getInt(key) ?? defaultValue;
|
||||
} catch (e) {
|
||||
throw CacheException('获取整数失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 存储布尔值
|
||||
Future<bool> setBool(String key, bool value) async {
|
||||
try {
|
||||
return await _prefs!.setBool(key, value);
|
||||
} catch (e) {
|
||||
throw CacheException('存储布尔值失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取布尔值
|
||||
bool? getBool(String key, {bool? defaultValue}) {
|
||||
try {
|
||||
return _prefs!.getBool(key) ?? defaultValue;
|
||||
} catch (e) {
|
||||
throw CacheException('获取布尔值失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 存储双精度浮点数
|
||||
Future<bool> setDouble(String key, double value) async {
|
||||
try {
|
||||
return await _prefs!.setDouble(key, value);
|
||||
} catch (e) {
|
||||
throw CacheException('存储双精度浮点数失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取双精度浮点数
|
||||
double? getDouble(String key, {double? defaultValue}) {
|
||||
try {
|
||||
return _prefs!.getDouble(key) ?? defaultValue;
|
||||
} catch (e) {
|
||||
throw CacheException('获取双精度浮点数失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 存储字符串列表
|
||||
Future<bool> setStringList(String key, List<String> value) async {
|
||||
try {
|
||||
return await _prefs!.setStringList(key, value);
|
||||
} catch (e) {
|
||||
throw CacheException('存储字符串列表失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取字符串列表
|
||||
List<String>? getStringList(String key, {List<String>? defaultValue}) {
|
||||
try {
|
||||
return _prefs!.getStringList(key) ?? defaultValue;
|
||||
} catch (e) {
|
||||
throw CacheException('获取字符串列表失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 存储JSON对象
|
||||
Future<bool> setJson(String key, Map<String, dynamic> value) async {
|
||||
try {
|
||||
final jsonString = jsonEncode(value);
|
||||
return await setString(key, jsonString);
|
||||
} catch (e) {
|
||||
throw CacheException('存储JSON对象失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取JSON对象
|
||||
Map<String, dynamic>? getJson(String key) {
|
||||
try {
|
||||
final jsonString = getString(key);
|
||||
if (jsonString == null) return null;
|
||||
return jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
throw CacheException('获取JSON对象失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除指定键的数据
|
||||
Future<bool> remove(String key) async {
|
||||
try {
|
||||
return await _prefs!.remove(key);
|
||||
} catch (e) {
|
||||
throw CacheException('删除数据失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空所有数据
|
||||
Future<bool> clear() async {
|
||||
try {
|
||||
return await _prefs!.clear();
|
||||
} catch (e) {
|
||||
throw CacheException('清空数据失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否包含指定键
|
||||
bool containsKey(String key) {
|
||||
try {
|
||||
return _prefs!.containsKey(key);
|
||||
} catch (e) {
|
||||
throw CacheException('检查键是否存在失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有键
|
||||
Set<String> getKeys() {
|
||||
try {
|
||||
return _prefs!.getKeys();
|
||||
} catch (e) {
|
||||
throw CacheException('获取所有键失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 安全存储 ====================
|
||||
|
||||
/// 安全存储字符串(用于敏感信息如Token)
|
||||
Future<void> setSecureString(String key, String value) async {
|
||||
try {
|
||||
await _prefs!.setString('secure_$key', value);
|
||||
} catch (e) {
|
||||
throw CacheException('安全存储字符串失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全获取字符串
|
||||
Future<String?> getSecureString(String key) async {
|
||||
try {
|
||||
return _prefs!.getString('secure_$key');
|
||||
} catch (e) {
|
||||
throw CacheException('安全获取字符串失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全存储JSON对象
|
||||
Future<void> setSecureJson(String key, Map<String, dynamic> value) async {
|
||||
try {
|
||||
final jsonString = jsonEncode(value);
|
||||
await setSecureString(key, jsonString);
|
||||
} catch (e) {
|
||||
throw CacheException('安全存储JSON对象失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全获取JSON对象
|
||||
Future<Map<String, dynamic>?> getSecureJson(String key) async {
|
||||
try {
|
||||
final jsonString = await getSecureString(key);
|
||||
if (jsonString == null) return null;
|
||||
return jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
throw CacheException('安全获取JSON对象失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全删除指定键的数据
|
||||
Future<void> removeSecure(String key) async {
|
||||
try {
|
||||
await _prefs!.remove('secure_$key');
|
||||
} catch (e) {
|
||||
throw CacheException('安全删除数据失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全清空所有数据
|
||||
Future<void> clearSecure() async {
|
||||
try {
|
||||
final keys = _prefs!.getKeys().where((key) => key.startsWith('secure_')).toList();
|
||||
for (final key in keys) {
|
||||
await _prefs!.remove(key);
|
||||
}
|
||||
} catch (e) {
|
||||
throw CacheException('安全清空数据失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全检查是否包含指定键
|
||||
Future<bool> containsSecureKey(String key) async {
|
||||
try {
|
||||
return _prefs!.containsKey('secure_$key');
|
||||
} catch (e) {
|
||||
throw CacheException('安全检查键是否存在失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全获取所有键
|
||||
Future<Map<String, String>> getAllSecure() async {
|
||||
try {
|
||||
final result = <String, String>{};
|
||||
final keys = _prefs!.getKeys().where((key) => key.startsWith('secure_'));
|
||||
for (final key in keys) {
|
||||
final value = _prefs!.getString(key);
|
||||
if (value != null) {
|
||||
result[key.substring(7)] = value; // 移除 'secure_' 前缀
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw CacheException('安全获取所有数据失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Token 相关便捷方法 ====================
|
||||
|
||||
/// 保存访问令牌
|
||||
Future<void> saveToken(String token) async {
|
||||
await setSecureString(StorageKeys.accessToken, token);
|
||||
}
|
||||
|
||||
/// 获取访问令牌
|
||||
Future<String?> getToken() async {
|
||||
return await getSecureString(StorageKeys.accessToken);
|
||||
}
|
||||
|
||||
/// 保存刷新令牌
|
||||
Future<void> saveRefreshToken(String refreshToken) async {
|
||||
await setSecureString(StorageKeys.refreshToken, refreshToken);
|
||||
}
|
||||
|
||||
/// 获取刷新令牌
|
||||
Future<String?> getRefreshToken() async {
|
||||
return await getSecureString(StorageKeys.refreshToken);
|
||||
}
|
||||
|
||||
/// 清除所有令牌
|
||||
Future<void> clearTokens() async {
|
||||
await removeSecure(StorageKeys.accessToken);
|
||||
await removeSecure(StorageKeys.refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// 存储键常量
|
||||
class StorageKeys {
|
||||
// 用户相关
|
||||
static const String accessToken = 'access_token';
|
||||
static const String refreshToken = 'refresh_token';
|
||||
static const String userInfo = 'user_info';
|
||||
static const String isLoggedIn = 'is_logged_in';
|
||||
static const String rememberMe = 'remember_me';
|
||||
|
||||
// 应用设置
|
||||
static const String appLanguage = 'app_language';
|
||||
static const String appTheme = 'app_theme';
|
||||
static const String firstLaunch = 'first_launch';
|
||||
static const String onboardingCompleted = 'onboarding_completed';
|
||||
|
||||
// 学习设置
|
||||
static const String dailyGoal = 'daily_goal';
|
||||
static const String reminderTimes = 'reminder_times';
|
||||
static const String notificationsEnabled = 'notifications_enabled';
|
||||
static const String soundEnabled = 'sound_enabled';
|
||||
static const String vibrationEnabled = 'vibration_enabled';
|
||||
|
||||
// 学习数据
|
||||
static const String learningProgress = 'learning_progress';
|
||||
static const String vocabularyProgress = 'vocabulary_progress';
|
||||
static const String listeningProgress = 'listening_progress';
|
||||
static const String readingProgress = 'reading_progress';
|
||||
static const String writingProgress = 'writing_progress';
|
||||
static const String speakingProgress = 'speaking_progress';
|
||||
|
||||
// 缓存数据
|
||||
static const String cachedWordBooks = 'cached_word_books';
|
||||
static const String cachedArticles = 'cached_articles';
|
||||
static const String cachedExercises = 'cached_exercises';
|
||||
|
||||
// 临时数据
|
||||
static const String tempData = 'temp_data';
|
||||
static const String draftData = 'draft_data';
|
||||
}
|
||||
151
client/lib/core/services/tts_service.dart
Normal file
151
client/lib/core/services/tts_service.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
|
||||
/// TTS语音播报服务
|
||||
class TTSService {
|
||||
static final TTSService _instance = TTSService._internal();
|
||||
factory TTSService() => _instance;
|
||||
TTSService._internal();
|
||||
|
||||
final FlutterTts _flutterTts = FlutterTts();
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 初始化TTS
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
// 设置语言为美式英语
|
||||
await _flutterTts.setLanguage("en-US");
|
||||
|
||||
// 设置语速 (0.0 - 1.0)
|
||||
await _flutterTts.setSpeechRate(0.5);
|
||||
|
||||
// 设置音量 (0.0 - 1.0)
|
||||
await _flutterTts.setVolume(1.0);
|
||||
|
||||
// 设置音调 (0.5 - 2.0)
|
||||
await _flutterTts.setPitch(1.0);
|
||||
|
||||
_isInitialized = true;
|
||||
print('TTS服务初始化成功');
|
||||
} catch (e) {
|
||||
print('TTS服务初始化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放单词发音
|
||||
/// [word] 要播放的单词
|
||||
/// [language] 语言代码,默认为美式英语 "en-US",英式英语为 "en-GB"
|
||||
Future<void> speak(String word, {String language = "en-US"}) async {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果正在播放,先停止
|
||||
await _flutterTts.stop();
|
||||
|
||||
// 设置语言
|
||||
await _flutterTts.setLanguage(language);
|
||||
|
||||
// 播放单词
|
||||
await _flutterTts.speak(word);
|
||||
|
||||
print('播放单词发音: $word ($language)');
|
||||
} catch (e) {
|
||||
print('播放单词发音失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止播放
|
||||
Future<void> stop() async {
|
||||
try {
|
||||
await _flutterTts.stop();
|
||||
} catch (e) {
|
||||
print('停止播放失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 暂停播放
|
||||
Future<void> pause() async {
|
||||
try {
|
||||
await _flutterTts.pause();
|
||||
} catch (e) {
|
||||
print('暂停播放失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置语速
|
||||
/// [rate] 语速 (0.0 - 1.0)
|
||||
Future<void> setSpeechRate(double rate) async {
|
||||
try {
|
||||
await _flutterTts.setSpeechRate(rate);
|
||||
} catch (e) {
|
||||
print('设置语速失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置音调
|
||||
/// [pitch] 音调 (0.5 - 2.0)
|
||||
Future<void> setPitch(double pitch) async {
|
||||
try {
|
||||
await _flutterTts.setPitch(pitch);
|
||||
} catch (e) {
|
||||
print('设置音调失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置音量
|
||||
/// [volume] 音量 (0.0 - 1.0)
|
||||
Future<void> setVolume(double volume) async {
|
||||
try {
|
||||
await _flutterTts.setVolume(volume);
|
||||
} catch (e) {
|
||||
print('设置音量失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取可用语言列表
|
||||
Future<List<dynamic>> getLanguages() async {
|
||||
try {
|
||||
return await _flutterTts.getLanguages;
|
||||
} catch (e) {
|
||||
print('获取语言列表失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取可用语音列表
|
||||
Future<List<dynamic>> getVoices() async {
|
||||
try {
|
||||
return await _flutterTts.getVoices;
|
||||
} catch (e) {
|
||||
print('获取语音列表失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置完成回调
|
||||
void setCompletionHandler(Function callback) {
|
||||
_flutterTts.setCompletionHandler(() {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
/// 设置错误回调
|
||||
void setErrorHandler(Function(dynamic) callback) {
|
||||
_flutterTts.setErrorHandler((msg) {
|
||||
callback(msg);
|
||||
});
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
Future<void> dispose() async {
|
||||
try {
|
||||
await _flutterTts.stop();
|
||||
_isInitialized = false;
|
||||
} catch (e) {
|
||||
print('释放TTS资源失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
150
client/lib/core/storage/storage_service.dart
Normal file
150
client/lib/core/storage/storage_service.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
/// 本地存储服务
|
||||
class StorageService {
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
/// 初始化存储服务
|
||||
static Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
/// 获取SharedPreferences实例
|
||||
static SharedPreferences get _instance {
|
||||
if (_prefs == null) {
|
||||
throw Exception('StorageService not initialized. Call StorageService.init() first.');
|
||||
}
|
||||
return _prefs!;
|
||||
}
|
||||
|
||||
/// 存储字符串
|
||||
static Future<bool> setString(String key, String value) async {
|
||||
return await _instance.setString(key, value);
|
||||
}
|
||||
|
||||
/// 获取字符串
|
||||
static String? getString(String key) {
|
||||
return _instance.getString(key);
|
||||
}
|
||||
|
||||
/// 存储整数
|
||||
static Future<bool> setInt(String key, int value) async {
|
||||
return await _instance.setInt(key, value);
|
||||
}
|
||||
|
||||
/// 获取整数
|
||||
static int? getInt(String key) {
|
||||
return _instance.getInt(key);
|
||||
}
|
||||
|
||||
/// 存储双精度浮点数
|
||||
static Future<bool> setDouble(String key, double value) async {
|
||||
return await _instance.setDouble(key, value);
|
||||
}
|
||||
|
||||
/// 获取双精度浮点数
|
||||
static double? getDouble(String key) {
|
||||
return _instance.getDouble(key);
|
||||
}
|
||||
|
||||
/// 存储布尔值
|
||||
static Future<bool> setBool(String key, bool value) async {
|
||||
return await _instance.setBool(key, value);
|
||||
}
|
||||
|
||||
/// 获取布尔值
|
||||
static bool? getBool(String key) {
|
||||
return _instance.getBool(key);
|
||||
}
|
||||
|
||||
/// 存储字符串列表
|
||||
static Future<bool> setStringList(String key, List<String> value) async {
|
||||
return await _instance.setStringList(key, value);
|
||||
}
|
||||
|
||||
/// 获取字符串列表
|
||||
static List<String>? getStringList(String key) {
|
||||
return _instance.getStringList(key);
|
||||
}
|
||||
|
||||
/// 存储对象(JSON序列化)
|
||||
static Future<bool> setObject(String key, Map<String, dynamic> value) async {
|
||||
final jsonString = jsonEncode(value);
|
||||
return await setString(key, jsonString);
|
||||
}
|
||||
|
||||
/// 获取对象(JSON反序列化)
|
||||
static Map<String, dynamic>? getObject(String key) {
|
||||
final jsonString = getString(key);
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
return jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
print('Error decoding JSON for key $key: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 存储对象列表(JSON序列化)
|
||||
static Future<bool> setObjectList(String key, List<Map<String, dynamic>> value) async {
|
||||
final jsonString = jsonEncode(value);
|
||||
return await setString(key, jsonString);
|
||||
}
|
||||
|
||||
/// 获取对象列表(JSON反序列化)
|
||||
static List<Map<String, dynamic>>? getObjectList(String key) {
|
||||
final jsonString = getString(key);
|
||||
if (jsonString == null) return null;
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(jsonString) as List;
|
||||
return decoded.cast<Map<String, dynamic>>();
|
||||
} catch (e) {
|
||||
print('Error decoding JSON list for key $key: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查键是否存在
|
||||
static bool containsKey(String key) {
|
||||
return _instance.containsKey(key);
|
||||
}
|
||||
|
||||
/// 删除指定键
|
||||
static Future<bool> remove(String key) async {
|
||||
return await _instance.remove(key);
|
||||
}
|
||||
|
||||
/// 清空所有数据
|
||||
static Future<bool> clear() async {
|
||||
return await _instance.clear();
|
||||
}
|
||||
|
||||
/// 获取所有键
|
||||
static Set<String> getKeys() {
|
||||
return _instance.getKeys();
|
||||
}
|
||||
|
||||
/// 重新加载数据
|
||||
static Future<void> reload() async {
|
||||
await _instance.reload();
|
||||
}
|
||||
}
|
||||
|
||||
/// 存储键名常量
|
||||
class StorageKeys {
|
||||
static const String accessToken = 'access_token';
|
||||
static const String refreshToken = 'refresh_token';
|
||||
static const String userInfo = 'user_info';
|
||||
static const String appSettings = 'app_settings';
|
||||
static const String learningProgress = 'learning_progress';
|
||||
static const String vocabularyCache = 'vocabulary_cache';
|
||||
static const String studyHistory = 'study_history';
|
||||
static const String offlineData = 'offline_data';
|
||||
static const String themeMode = 'theme_mode';
|
||||
static const String language = 'language';
|
||||
static const String firstLaunch = 'first_launch';
|
||||
static const String onboardingCompleted = 'onboarding_completed';
|
||||
}
|
||||
278
client/lib/core/theme/app_colors.dart
Normal file
278
client/lib/core/theme/app_colors.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 应用颜色常量
|
||||
class AppColors {
|
||||
// 私有构造函数,防止实例化
|
||||
AppColors._();
|
||||
|
||||
// ============ 浅色主题颜色 ============
|
||||
|
||||
/// 主色调
|
||||
static const Color primary = Color(0xFF1976D2);
|
||||
static const Color onPrimary = Color(0xFFFFFFFF);
|
||||
static const Color primaryContainer = Color(0xFFBBDEFB);
|
||||
static const Color onPrimaryContainer = Color(0xFF0D47A1);
|
||||
|
||||
/// 次要色调
|
||||
static const Color secondary = Color(0xFF03DAC6);
|
||||
static const Color onSecondary = Color(0xFF000000);
|
||||
static const Color secondaryContainer = Color(0xFFB2DFDB);
|
||||
static const Color onSecondaryContainer = Color(0xFF004D40);
|
||||
|
||||
/// 第三色调
|
||||
static const Color tertiary = Color(0xFF9C27B0);
|
||||
static const Color onTertiary = Color(0xFFFFFFFF);
|
||||
static const Color tertiaryContainer = Color(0xFFE1BEE7);
|
||||
static const Color onTertiaryContainer = Color(0xFF4A148C);
|
||||
|
||||
/// 错误色
|
||||
static const Color error = Color(0xFFD32F2F);
|
||||
static const Color onError = Color(0xFFFFFFFF);
|
||||
static const Color errorContainer = Color(0xFFFFCDD2);
|
||||
static const Color onErrorContainer = Color(0xFFB71C1C);
|
||||
|
||||
/// 背景颜色
|
||||
static const Color background = Color(0xFFFFFBFE);
|
||||
static const Color onBackground = Color(0xFF1C1B1F);
|
||||
|
||||
/// 表面色
|
||||
static const Color surface = Color(0xFFFFFFFF);
|
||||
static const Color onSurface = Color(0xFF1C1B1F);
|
||||
static const Color surfaceTint = Color(0xFF1976D2);
|
||||
|
||||
/// 表面变体色
|
||||
static const Color surfaceVariant = Color(0xFFF3F3F3);
|
||||
static const Color onSurfaceVariant = Color(0xFF49454F);
|
||||
|
||||
/// 轮廓色
|
||||
static const Color outline = Color(0xFF79747E);
|
||||
static const Color outlineVariant = Color(0xFFCAC4D0);
|
||||
|
||||
/// 阴影颜色
|
||||
static const Color shadow = Color(0xFF000000);
|
||||
static const Color scrim = Color(0xFF000000);
|
||||
|
||||
/// 反转颜色
|
||||
static const Color inverseSurface = Color(0xFF313033);
|
||||
static const Color onInverseSurface = Color(0xFFF4EFF4);
|
||||
static const Color inversePrimary = Color(0xFF90CAF9);
|
||||
|
||||
// ============ 深色主题颜色 ============
|
||||
|
||||
/// 主色调 - 深色
|
||||
static const Color primaryDark = Color(0xFF90CAF9);
|
||||
static const Color onPrimaryDark = Color(0xFF003C8F);
|
||||
static const Color primaryContainerDark = Color(0xFF1565C0);
|
||||
static const Color onPrimaryContainerDark = Color(0xFFE3F2FD);
|
||||
|
||||
/// 次要色调 - 深色
|
||||
static const Color secondaryDark = Color(0xFF80CBC4);
|
||||
static const Color onSecondaryDark = Color(0xFF00251A);
|
||||
static const Color secondaryContainerDark = Color(0xFF00695C);
|
||||
static const Color onSecondaryContainerDark = Color(0xFFE0F2F1);
|
||||
|
||||
/// 第三色调 - 深色
|
||||
static const Color tertiaryDark = Color(0xFFCE93D8);
|
||||
static const Color onTertiaryDark = Color(0xFF4A148C);
|
||||
static const Color tertiaryContainerDark = Color(0xFF7B1FA2);
|
||||
static const Color onTertiaryContainerDark = Color(0xFFF3E5F5);
|
||||
|
||||
/// 错误色 - 深色
|
||||
static const Color errorDark = Color(0xFFEF5350);
|
||||
static const Color onErrorDark = Color(0xFF690005);
|
||||
static const Color errorContainerDark = Color(0xFFD32F2F);
|
||||
static const Color onErrorContainerDark = Color(0xFFFFEBEE);
|
||||
|
||||
/// 背景颜色 - 深色
|
||||
static const Color backgroundDark = Color(0xFF1C1B1F);
|
||||
static const Color onBackgroundDark = Color(0xFFE6E1E5);
|
||||
|
||||
/// 表面色 - 深色
|
||||
static const Color surfaceDark = Color(0xFF121212);
|
||||
static const Color onSurfaceDark = Color(0xFFE6E1E5);
|
||||
static const Color surfaceTintDark = Color(0xFF90CAF9);
|
||||
|
||||
/// 表面变体色 - 深色
|
||||
static const Color surfaceVariantDark = Color(0xFF49454F);
|
||||
static const Color onSurfaceVariantDark = Color(0xFFCAC4D0);
|
||||
|
||||
/// 轮廓色 - 深色
|
||||
static const Color outlineDark = Color(0xFF938F99);
|
||||
static const Color outlineVariantDark = Color(0xFF49454F);
|
||||
|
||||
/// 阴影颜色 - 深色
|
||||
static const Color shadowDark = Color(0xFF000000);
|
||||
static const Color scrimDark = Color(0xFF000000);
|
||||
|
||||
/// 反转色 - 深色
|
||||
static const Color inverseSurfaceDark = Color(0xFFE6E1E5);
|
||||
static const Color onInverseSurfaceDark = Color(0xFF313033);
|
||||
static const Color inversePrimaryDark = Color(0xFF1976D2);
|
||||
|
||||
// ============ 功能性颜色 ============
|
||||
|
||||
/// 成功色
|
||||
static const Color success = Color(0xFF4CAF50);
|
||||
static const Color onSuccess = Color(0xFFFFFFFF);
|
||||
static const Color successContainer = Color(0xFFE8F5E8);
|
||||
static const Color onSuccessContainer = Color(0xFF1B5E20);
|
||||
|
||||
/// 警告色
|
||||
static const Color warning = Color(0xFFFF9800);
|
||||
static const Color onWarning = Color(0xFFFFFFFF);
|
||||
static const Color warningContainer = Color(0xFFFFF3E0);
|
||||
static const Color onWarningContainer = Color(0xFFE65100);
|
||||
|
||||
/// 信息色
|
||||
static const Color info = Color(0xFF2196F3);
|
||||
static const Color onInfo = Color(0xFFFFFFFF);
|
||||
static const Color infoContainer = Color(0xFFE3F2FD);
|
||||
static const Color onInfoContainer = Color(0xFF0D47A1);
|
||||
|
||||
// ============ 学习模块颜色 ============
|
||||
|
||||
/// 词汇学习
|
||||
static const Color vocabulary = Color(0xFF9C27B0);
|
||||
static const Color onVocabulary = Color(0xFFFFFFFF);
|
||||
static const Color vocabularyContainer = Color(0xFFF3E5F5);
|
||||
static const Color onVocabularyContainer = Color(0xFF4A148C);
|
||||
static const Color vocabularyDark = Color(0xFFBA68C8);
|
||||
|
||||
/// 听力训练
|
||||
static const Color listening = Color(0xFF00BCD4);
|
||||
static const Color onListening = Color(0xFFFFFFFF);
|
||||
static const Color listeningContainer = Color(0xFFE0F7FA);
|
||||
static const Color onListeningContainer = Color(0xFF006064);
|
||||
static const Color listeningDark = Color(0xFF4DD0E1);
|
||||
|
||||
/// 阅读理解
|
||||
static const Color reading = Color(0xFF4CAF50);
|
||||
static const Color onReading = Color(0xFFFFFFFF);
|
||||
static const Color readingContainer = Color(0xFFE8F5E8);
|
||||
static const Color onReadingContainer = Color(0xFF1B5E20);
|
||||
static const Color readingDark = Color(0xFF81C784);
|
||||
|
||||
/// 写作练习
|
||||
static const Color writing = Color(0xFFFF5722);
|
||||
static const Color onWriting = Color(0xFFFFFFFF);
|
||||
static const Color writingContainer = Color(0xFFFBE9E7);
|
||||
static const Color onWritingContainer = Color(0xFFBF360C);
|
||||
static const Color writingDark = Color(0xFFFF8A65);
|
||||
|
||||
/// 口语练习
|
||||
static const Color speaking = Color(0xFFE91E63);
|
||||
static const Color onSpeaking = Color(0xFFFFFFFF);
|
||||
static const Color speakingContainer = Color(0xFFFCE4EC);
|
||||
static const Color onSpeakingContainer = Color(0xFF880E4F);
|
||||
static const Color speakingDark = Color(0xFFF06292);
|
||||
|
||||
// ============ 等级颜色 ============
|
||||
|
||||
/// 初级
|
||||
static const Color beginner = Color(0xFF4CAF50);
|
||||
static const Color onBeginner = Color(0xFFFFFFFF);
|
||||
static const Color beginnerDark = Color(0xFF81C784);
|
||||
|
||||
/// 中级
|
||||
static const Color intermediate = Color(0xFFFF9800);
|
||||
static const Color onIntermediate = Color(0xFFFFFFFF);
|
||||
static const Color intermediateDark = Color(0xFFFFB74D);
|
||||
|
||||
/// 高级
|
||||
static const Color advanced = Color(0xFFF44336);
|
||||
static const Color onAdvanced = Color(0xFFFFFFFF);
|
||||
static const Color advancedDark = Color(0xFFEF5350);
|
||||
|
||||
// ============ 进度颜色 ============
|
||||
|
||||
/// 进度条背景
|
||||
static const Color progressBackground = Color(0xFFE0E0E0);
|
||||
|
||||
/// 进度条前景
|
||||
static const Color progressForeground = Color(0xFF2196F3);
|
||||
|
||||
/// 完成状态
|
||||
static const Color completed = Color(0xFF4CAF50);
|
||||
|
||||
/// 进行中状态
|
||||
static const Color inProgress = Color(0xFFFF9800);
|
||||
|
||||
/// 未开始状态
|
||||
static const Color notStarted = Color(0xFFBDBDBD);
|
||||
|
||||
/// 进度等级颜色
|
||||
static const Color progressLow = Color(0xFFF44336);
|
||||
static const Color progressLowDark = Color(0xFFEF5350);
|
||||
static const Color progressMedium = Color(0xFFFF9800);
|
||||
static const Color progressMediumDark = Color(0xFFFFB74D);
|
||||
static const Color progressHigh = Color(0xFF4CAF50);
|
||||
static const Color progressHighDark = Color(0xFF81C784);
|
||||
|
||||
// ============ 特殊颜色 ============
|
||||
|
||||
/// 分割线
|
||||
static const Color divider = Color(0xFFE0E0E0);
|
||||
|
||||
/// 禁用状态
|
||||
static const Color disabled = Color(0xFFBDBDBD);
|
||||
static const Color onDisabled = Color(0xFF757575);
|
||||
|
||||
/// 透明度变体
|
||||
static Color get primaryWithOpacity => primary.withValues(alpha: 0.12);
|
||||
static Color get secondaryWithOpacity => secondary.withValues(alpha: 0.12);
|
||||
static Color get errorWithOpacity => error.withValues(alpha: 0.12);
|
||||
static Color get successWithOpacity => success.withValues(alpha: 0.12);
|
||||
static Color get warningWithOpacity => warning.withValues(alpha: 0.12);
|
||||
static Color get infoWithOpacity => info.withValues(alpha: 0.12);
|
||||
|
||||
// ============ 渐变色 ============
|
||||
|
||||
/// 主要渐变
|
||||
static const LinearGradient primaryGradient = LinearGradient(
|
||||
colors: [Color(0xFF2196F3), Color(0xFF21CBF3)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
||||
/// 次要渐变
|
||||
static const LinearGradient secondaryGradient = LinearGradient(
|
||||
colors: [Color(0xFF03DAC6), Color(0xFF00BCD4)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
||||
/// 词汇渐变
|
||||
static const LinearGradient vocabularyGradient = LinearGradient(
|
||||
colors: [Color(0xFF9C27B0), Color(0xFFE91E63)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
||||
/// 听力渐变
|
||||
static const LinearGradient listeningGradient = LinearGradient(
|
||||
colors: [Color(0xFF00BCD4), Color(0xFF2196F3)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
||||
/// 阅读渐变
|
||||
static const LinearGradient readingGradient = LinearGradient(
|
||||
colors: [Color(0xFF4CAF50), Color(0xFF8BC34A)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
||||
/// 写作渐变
|
||||
static const LinearGradient writingGradient = LinearGradient(
|
||||
colors: [Color(0xFFFF5722), Color(0xFFFF9800)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
||||
/// 口语渐变
|
||||
static const LinearGradient speakingGradient = LinearGradient(
|
||||
colors: [Color(0xFFE91E63), Color(0xFF9C27B0)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
}
|
||||
365
client/lib/core/theme/app_dimensions.dart
Normal file
365
client/lib/core/theme/app_dimensions.dart
Normal file
@@ -0,0 +1,365 @@
|
||||
/// 应用尺寸配置
|
||||
class AppDimensions {
|
||||
// 私有构造函数,防止实例化
|
||||
AppDimensions._();
|
||||
|
||||
// ============ 间距 ============
|
||||
|
||||
/// 极小间距
|
||||
static const double spacingXs = 4.0;
|
||||
|
||||
/// 小间距
|
||||
static const double spacingSm = 8.0;
|
||||
|
||||
/// 中等间距
|
||||
static const double spacingMd = 16.0;
|
||||
|
||||
/// 大间距
|
||||
static const double spacingLg = 24.0;
|
||||
|
||||
/// 超大间距
|
||||
static const double spacingXl = 32.0;
|
||||
|
||||
/// 超超大间距
|
||||
static const double spacingXxl = 48.0;
|
||||
|
||||
// ============ 内边距 ============
|
||||
|
||||
/// 页面内边距
|
||||
static const double pagePadding = spacingMd;
|
||||
|
||||
/// 卡片内边距
|
||||
static const double cardPadding = spacingMd;
|
||||
|
||||
/// 按钮内边距
|
||||
static const double buttonPadding = spacingMd;
|
||||
|
||||
/// 输入框内边距
|
||||
static const double inputPadding = spacingMd;
|
||||
|
||||
/// 列表项内边距
|
||||
static const double listItemPadding = spacingMd;
|
||||
|
||||
/// 对话框内边距
|
||||
static const double dialogPadding = spacingLg;
|
||||
|
||||
/// 底部导航栏内边距
|
||||
static const double bottomNavPadding = spacingSm;
|
||||
|
||||
/// AppBar内边距
|
||||
static const double appBarPadding = spacingMd;
|
||||
|
||||
// ============ 外边距 ============
|
||||
|
||||
/// 页面外边距
|
||||
static const double pageMargin = spacingMd;
|
||||
|
||||
/// 卡片外边距
|
||||
static const double cardMargin = spacingSm;
|
||||
|
||||
/// 按钮外边距
|
||||
static const double buttonMargin = spacingSm;
|
||||
|
||||
/// 输入框外边距
|
||||
static const double inputMargin = spacingSm;
|
||||
|
||||
/// 列表项外边距
|
||||
static const double listItemMargin = spacingXs;
|
||||
|
||||
/// 对话框外边距
|
||||
static const double dialogMargin = spacingLg;
|
||||
|
||||
// ============ 圆角半径 ============
|
||||
|
||||
/// 极小圆角
|
||||
static const double radiusXs = 4.0;
|
||||
|
||||
/// 小圆角
|
||||
static const double radiusSm = 8.0;
|
||||
|
||||
/// 中等圆角
|
||||
static const double radiusMd = 12.0;
|
||||
|
||||
/// 大圆角
|
||||
static const double radiusLg = 16.0;
|
||||
|
||||
/// 超大圆角
|
||||
static const double radiusXl = 20.0;
|
||||
|
||||
/// 圆形
|
||||
static const double radiusCircle = 999.0;
|
||||
|
||||
// ============ 组件圆角 ============
|
||||
|
||||
/// 按钮圆角
|
||||
static const double buttonRadius = radiusSm;
|
||||
|
||||
/// 卡片圆角
|
||||
static const double cardRadius = radiusMd;
|
||||
|
||||
/// 输入框圆角
|
||||
static const double inputRadius = radiusSm;
|
||||
|
||||
/// 对话框圆角
|
||||
static const double dialogRadius = radiusLg;
|
||||
|
||||
/// 底部弹窗圆角
|
||||
static const double bottomSheetRadius = radiusLg;
|
||||
|
||||
/// 芯片圆角
|
||||
static const double chipRadius = radiusLg;
|
||||
|
||||
/// 头像圆角
|
||||
static const double avatarRadius = radiusCircle;
|
||||
|
||||
/// 图片圆角
|
||||
static const double imageRadius = radiusSm;
|
||||
|
||||
// ============ 高度 ============
|
||||
|
||||
/// AppBar高度
|
||||
static const double appBarHeight = 56.0;
|
||||
|
||||
/// 底部导航栏高度
|
||||
static const double bottomNavHeight = 80.0;
|
||||
|
||||
/// 标签栏高度
|
||||
static const double tabBarHeight = 48.0;
|
||||
|
||||
/// 按钮高度
|
||||
static const double buttonHeight = 48.0;
|
||||
|
||||
/// 小按钮高度
|
||||
static const double buttonHeightSm = 36.0;
|
||||
|
||||
/// 大按钮高度
|
||||
static const double buttonHeightLg = 56.0;
|
||||
|
||||
/// 输入框高度
|
||||
static const double inputHeight = 56.0;
|
||||
|
||||
/// 列表项高度
|
||||
static const double listItemHeight = 56.0;
|
||||
|
||||
/// 小列表项高度
|
||||
static const double listItemHeightSm = 48.0;
|
||||
|
||||
/// 大列表项高度
|
||||
static const double listItemHeightLg = 72.0;
|
||||
|
||||
/// 工具栏高度
|
||||
static const double toolbarHeight = 56.0;
|
||||
|
||||
/// 搜索栏高度
|
||||
static const double searchBarHeight = 48.0;
|
||||
|
||||
/// 进度条高度
|
||||
static const double progressBarHeight = 4.0;
|
||||
|
||||
/// 分割线高度
|
||||
static const double dividerHeight = 1.0;
|
||||
|
||||
// ============ 宽度 ============
|
||||
|
||||
/// 最小按钮宽度
|
||||
static const double buttonMinWidth = 64.0;
|
||||
|
||||
/// 侧边栏宽度
|
||||
static const double drawerWidth = 304.0;
|
||||
|
||||
/// 分割线宽度
|
||||
static const double dividerWidth = 1.0;
|
||||
|
||||
/// 边框宽度
|
||||
static const double borderWidth = 1.0;
|
||||
|
||||
/// 粗边框宽度
|
||||
static const double borderWidthThick = 2.0;
|
||||
|
||||
// ============ 图标尺寸 ============
|
||||
|
||||
/// 极小图标
|
||||
static const double iconXs = 16.0;
|
||||
|
||||
/// 小图标
|
||||
static const double iconSm = 20.0;
|
||||
|
||||
/// 中等图标
|
||||
static const double iconMd = 24.0;
|
||||
|
||||
/// 大图标
|
||||
static const double iconLg = 32.0;
|
||||
|
||||
/// 超大图标
|
||||
static const double iconXl = 48.0;
|
||||
|
||||
/// 超超大图标
|
||||
static const double iconXxl = 64.0;
|
||||
|
||||
// ============ 头像尺寸 ============
|
||||
|
||||
/// 小头像
|
||||
static const double avatarSm = 32.0;
|
||||
|
||||
/// 中等头像
|
||||
static const double avatarMd = 48.0;
|
||||
|
||||
/// 大头像
|
||||
static const double avatarLg = 64.0;
|
||||
|
||||
/// 超大头像
|
||||
static const double avatarXl = 96.0;
|
||||
|
||||
// ============ 阴影 ============
|
||||
|
||||
/// 阴影偏移
|
||||
static const double shadowOffset = 2.0;
|
||||
|
||||
/// 阴影模糊半径
|
||||
static const double shadowBlurRadius = 8.0;
|
||||
|
||||
/// 阴影扩散半径
|
||||
static const double shadowSpreadRadius = 0.0;
|
||||
|
||||
// ============ 动画持续时间 ============
|
||||
|
||||
/// 快速动画
|
||||
static const Duration animationFast = Duration(milliseconds: 150);
|
||||
|
||||
/// 中等动画
|
||||
static const Duration animationMedium = Duration(milliseconds: 300);
|
||||
|
||||
/// 慢速动画
|
||||
static const Duration animationSlow = Duration(milliseconds: 500);
|
||||
|
||||
// ============ 学习相关尺寸 ============
|
||||
|
||||
/// 单词卡片高度
|
||||
static const double wordCardHeight = 200.0;
|
||||
|
||||
/// 单词卡片宽度
|
||||
static const double wordCardWidth = 300.0;
|
||||
|
||||
/// 进度圆环大小
|
||||
static const double progressCircleSize = 120.0;
|
||||
|
||||
/// 等级徽章大小
|
||||
static const double levelBadgeSize = 40.0;
|
||||
|
||||
/// 成就徽章大小
|
||||
static const double achievementBadgeSize = 60.0;
|
||||
|
||||
/// 音频播放器高度
|
||||
static const double audioPlayerHeight = 80.0;
|
||||
|
||||
/// 练习题选项高度
|
||||
static const double exerciseOptionHeight = 48.0;
|
||||
|
||||
/// 学习统计卡片高度
|
||||
static const double statsCardHeight = 120.0;
|
||||
|
||||
// ============ 响应式断点 ============
|
||||
|
||||
/// 手机断点
|
||||
static const double mobileBreakpoint = 600.0;
|
||||
|
||||
/// 平板断点
|
||||
static const double tabletBreakpoint = 900.0;
|
||||
|
||||
/// 桌面断点
|
||||
static const double desktopBreakpoint = 1200.0;
|
||||
|
||||
// ============ 最大宽度 ============
|
||||
|
||||
/// 内容最大宽度
|
||||
static const double maxContentWidth = 1200.0;
|
||||
|
||||
/// 对话框最大宽度
|
||||
static const double maxDialogWidth = 560.0;
|
||||
|
||||
/// 卡片最大宽度
|
||||
static const double maxCardWidth = 400.0;
|
||||
|
||||
// ============ 最小尺寸 ============
|
||||
|
||||
/// 最小触摸目标尺寸
|
||||
static const double minTouchTarget = 48.0;
|
||||
|
||||
/// 最小按钮尺寸
|
||||
static const double minButtonSize = 36.0;
|
||||
|
||||
// ============ 网格布局 ============
|
||||
|
||||
/// 网格间距
|
||||
static const double gridSpacing = spacingSm;
|
||||
|
||||
/// 网格交叉轴间距
|
||||
static const double gridCrossAxisSpacing = spacingSm;
|
||||
|
||||
/// 网格主轴间距
|
||||
static const double gridMainAxisSpacing = spacingSm;
|
||||
|
||||
/// 网格子项宽高比
|
||||
static const double gridChildAspectRatio = 1.0;
|
||||
|
||||
// ============ 列表布局 ============
|
||||
|
||||
/// 列表分割线缩进
|
||||
static const double listDividerIndent = spacingMd;
|
||||
|
||||
/// 列表分割线结束缩进
|
||||
static const double listDividerEndIndent = spacingMd;
|
||||
|
||||
// ============ 浮动操作按钮 ============
|
||||
|
||||
/// 浮动操作按钮大小
|
||||
static const double fabSize = 56.0;
|
||||
|
||||
/// 小浮动操作按钮大小
|
||||
static const double fabSizeSmall = 40.0;
|
||||
|
||||
/// 大浮动操作按钮大小
|
||||
static const double fabSizeLarge = 96.0;
|
||||
|
||||
/// 浮动操作按钮边距
|
||||
static const double fabMargin = spacingMd;
|
||||
|
||||
// ============ 辅助方法 ============
|
||||
|
||||
/// 根据屏幕宽度获取响应式间距
|
||||
static double getResponsiveSpacing(double screenWidth) {
|
||||
if (screenWidth < mobileBreakpoint) {
|
||||
return spacingSm;
|
||||
} else if (screenWidth < tabletBreakpoint) {
|
||||
return spacingMd;
|
||||
} else {
|
||||
return spacingLg;
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据屏幕宽度获取响应式内边距
|
||||
static double getResponsivePadding(double screenWidth) {
|
||||
if (screenWidth < mobileBreakpoint) {
|
||||
return spacingMd;
|
||||
} else if (screenWidth < tabletBreakpoint) {
|
||||
return spacingLg;
|
||||
} else {
|
||||
return spacingXl;
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据屏幕宽度判断是否为移动设备
|
||||
static bool isMobile(double screenWidth) {
|
||||
return screenWidth < mobileBreakpoint;
|
||||
}
|
||||
|
||||
/// 根据屏幕宽度判断是否为平板设备
|
||||
static bool isTablet(double screenWidth) {
|
||||
return screenWidth >= mobileBreakpoint && screenWidth < desktopBreakpoint;
|
||||
}
|
||||
|
||||
/// 根据屏幕宽度判断是否为桌面设备
|
||||
static bool isDesktop(double screenWidth) {
|
||||
return screenWidth >= desktopBreakpoint;
|
||||
}
|
||||
}
|
||||
437
client/lib/core/theme/app_text_styles.dart
Normal file
437
client/lib/core/theme/app_text_styles.dart
Normal file
@@ -0,0 +1,437 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// 应用文本样式配置
|
||||
class AppTextStyles {
|
||||
// 私有构造函数,防止实例化
|
||||
AppTextStyles._();
|
||||
|
||||
// ============ 字体家族 ============
|
||||
|
||||
/// 默认字体 - 使用系统默认,不指定字体名称
|
||||
static const String? defaultFontFamily = null;
|
||||
|
||||
/// 中文字体 - 使用系统默认,不指定字体名称
|
||||
static const String? chineseFontFamily = null;
|
||||
|
||||
/// 等宽字体 - 使用系统默认,不指定字体名称
|
||||
static const String? monospaceFontFamily = null;
|
||||
|
||||
// ============ 字体权重 ============
|
||||
|
||||
static const FontWeight light = FontWeight.w300;
|
||||
static const FontWeight regular = FontWeight.w400;
|
||||
static const FontWeight medium = FontWeight.w500;
|
||||
static const FontWeight semiBold = FontWeight.w600;
|
||||
static const FontWeight bold = FontWeight.w700;
|
||||
static const FontWeight extraBold = FontWeight.w800;
|
||||
|
||||
// ============ 基础文本样式 ============
|
||||
|
||||
/// 标题样式
|
||||
static const TextStyle displayLarge = TextStyle(
|
||||
fontSize: 57,
|
||||
fontWeight: bold,
|
||||
letterSpacing: -0.25,
|
||||
height: 1.12,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle displayMedium = TextStyle(
|
||||
fontSize: 45,
|
||||
fontWeight: bold,
|
||||
letterSpacing: 0,
|
||||
height: 1.16,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle displaySmall = TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: bold,
|
||||
letterSpacing: 0,
|
||||
height: 1.22,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
/// 标题样式
|
||||
static const TextStyle headlineLarge = TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: bold,
|
||||
letterSpacing: 0,
|
||||
height: 1.25,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle headlineMedium = TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: semiBold,
|
||||
letterSpacing: 0,
|
||||
height: 1.29,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle headlineSmall = TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: semiBold,
|
||||
letterSpacing: 0,
|
||||
height: 1.33,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
/// 标题样式
|
||||
static const TextStyle titleLarge = TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: semiBold,
|
||||
letterSpacing: 0,
|
||||
height: 1.27,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle titleMedium = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.15,
|
||||
height: 1.50,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle titleSmall = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.43,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
/// 标签样式
|
||||
static const TextStyle labelLarge = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.43,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle labelMedium = TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.33,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle labelSmall = TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.45,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
/// 正文样式
|
||||
static const TextStyle bodyLarge = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.50,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle bodyMedium = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.25,
|
||||
height: 1.43,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle bodySmall = TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.4,
|
||||
height: 1.33,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
);
|
||||
|
||||
// ============ 自定义文本样式 ============
|
||||
|
||||
/// 按钮文本样式
|
||||
static const TextStyle buttonLarge = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: semiBold,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.25,
|
||||
color: AppColors.onPrimary,
|
||||
);
|
||||
|
||||
static const TextStyle buttonMedium = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: semiBold,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.29,
|
||||
color: AppColors.onPrimary,
|
||||
);
|
||||
|
||||
static const TextStyle buttonSmall = TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.33,
|
||||
color: AppColors.onPrimary,
|
||||
);
|
||||
|
||||
/// 输入框文本样式
|
||||
static const TextStyle inputText = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.50,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle inputLabel = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.15,
|
||||
height: 1.50,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
);
|
||||
|
||||
static const TextStyle inputHint = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.50,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
);
|
||||
|
||||
static const TextStyle inputError = TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.4,
|
||||
height: 1.33,
|
||||
color: AppColors.error,
|
||||
);
|
||||
|
||||
/// 导航文本样式
|
||||
static const TextStyle navigationLabel = TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.33,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle tabLabel = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.43,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
/// 卡片文本样式
|
||||
static const TextStyle cardTitle = TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: semiBold,
|
||||
letterSpacing: 0,
|
||||
height: 1.33,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle cardSubtitle = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.25,
|
||||
height: 1.43,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
);
|
||||
|
||||
static const TextStyle cardBody = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.25,
|
||||
height: 1.43,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
/// 列表文本样式
|
||||
static const TextStyle listTitle = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.15,
|
||||
height: 1.50,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle listSubtitle = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.25,
|
||||
height: 1.43,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
);
|
||||
|
||||
/// 学习相关文本样式
|
||||
static const TextStyle wordText = TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: semiBold,
|
||||
letterSpacing: 0,
|
||||
height: 1.33,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle phoneticText = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.50,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontFamily: monospaceFontFamily,
|
||||
);
|
||||
|
||||
static const TextStyle definitionText = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.50,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle exampleText = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: regular,
|
||||
letterSpacing: 0.25,
|
||||
height: 1.43,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
);
|
||||
|
||||
/// 分数和统计文本样式
|
||||
static const TextStyle scoreText = TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: bold,
|
||||
letterSpacing: 0,
|
||||
height: 1.25,
|
||||
color: AppColors.primary,
|
||||
);
|
||||
|
||||
static const TextStyle statisticNumber = TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: semiBold,
|
||||
letterSpacing: 0,
|
||||
height: 1.33,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
|
||||
static const TextStyle statisticLabel = TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.33,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
);
|
||||
|
||||
/// 状态文本样式
|
||||
static const TextStyle successText = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.43,
|
||||
color: AppColors.success,
|
||||
);
|
||||
|
||||
static const TextStyle warningText = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.43,
|
||||
color: AppColors.warning,
|
||||
);
|
||||
|
||||
static const TextStyle errorText = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.43,
|
||||
color: AppColors.error,
|
||||
);
|
||||
|
||||
static const TextStyle infoText = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: medium,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.43,
|
||||
color: AppColors.info,
|
||||
);
|
||||
|
||||
// ============ Material 3 文本主题 ============
|
||||
|
||||
/// Material 3 文本主题
|
||||
static const TextTheme textTheme = TextTheme(
|
||||
displayLarge: displayLarge,
|
||||
displayMedium: displayMedium,
|
||||
displaySmall: displaySmall,
|
||||
headlineLarge: headlineLarge,
|
||||
headlineMedium: headlineMedium,
|
||||
headlineSmall: headlineSmall,
|
||||
titleLarge: titleLarge,
|
||||
titleMedium: titleMedium,
|
||||
titleSmall: titleSmall,
|
||||
labelLarge: labelLarge,
|
||||
labelMedium: labelMedium,
|
||||
labelSmall: labelSmall,
|
||||
bodyLarge: bodyLarge,
|
||||
bodyMedium: bodyMedium,
|
||||
bodySmall: bodySmall,
|
||||
);
|
||||
|
||||
// ============ 辅助方法 ============
|
||||
|
||||
/// 根据主题亮度调整文本颜色
|
||||
static TextStyle adaptiveTextStyle(TextStyle style, Brightness brightness) {
|
||||
if (brightness == Brightness.dark) {
|
||||
return style.copyWith(
|
||||
color: _adaptColorForDarkTheme(style.color ?? AppColors.onSurface),
|
||||
);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
/// 为深色主题调整颜色
|
||||
static Color _adaptColorForDarkTheme(Color color) {
|
||||
if (color == AppColors.onSurface) {
|
||||
return AppColors.onSurfaceDark;
|
||||
} else if (color == AppColors.onSurfaceVariant) {
|
||||
return AppColors.onSurfaceVariantDark;
|
||||
} else if (color == AppColors.primary) {
|
||||
return AppColors.primaryDark;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/// 创建带有特定颜色的文本样式
|
||||
static TextStyle withColor(TextStyle style, Color color) {
|
||||
return style.copyWith(color: color);
|
||||
}
|
||||
|
||||
/// 创建带有特定字体大小的文本样式
|
||||
static TextStyle withFontSize(TextStyle style, double fontSize) {
|
||||
return style.copyWith(fontSize: fontSize);
|
||||
}
|
||||
|
||||
/// 创建带有特定字体权重的文本样式
|
||||
static TextStyle withFontWeight(TextStyle style, FontWeight fontWeight) {
|
||||
return style.copyWith(fontWeight: fontWeight);
|
||||
}
|
||||
|
||||
/// 创建带有特定行高的文本样式
|
||||
static TextStyle withHeight(TextStyle style, double height) {
|
||||
return style.copyWith(height: height);
|
||||
}
|
||||
|
||||
/// 创建带有特定字母间距的文本样式
|
||||
static TextStyle withLetterSpacing(TextStyle style, double letterSpacing) {
|
||||
return style.copyWith(letterSpacing: letterSpacing);
|
||||
}
|
||||
}
|
||||
424
client/lib/core/theme/app_theme.dart
Normal file
424
client/lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,424 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'app_colors.dart';
|
||||
import 'app_text_styles.dart';
|
||||
import 'app_dimensions.dart';
|
||||
|
||||
/// 应用主题配置
|
||||
class AppTheme {
|
||||
// 私有构造函数,防止实例化
|
||||
AppTheme._();
|
||||
|
||||
// ============ 亮色主题 ============
|
||||
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
|
||||
// 禁用Google Fonts,使用系统默认字体
|
||||
fontFamily: null, // 不指定字体,使用系统默认
|
||||
|
||||
// 颜色方案
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: AppColors.primary,
|
||||
onPrimary: AppColors.onPrimary,
|
||||
primaryContainer: AppColors.primaryContainer,
|
||||
onPrimaryContainer: AppColors.onPrimaryContainer,
|
||||
secondary: AppColors.secondary,
|
||||
onSecondary: AppColors.onSecondary,
|
||||
secondaryContainer: AppColors.secondaryContainer,
|
||||
onSecondaryContainer: AppColors.onSecondaryContainer,
|
||||
tertiary: AppColors.tertiary,
|
||||
onTertiary: AppColors.onTertiary,
|
||||
tertiaryContainer: AppColors.tertiaryContainer,
|
||||
onTertiaryContainer: AppColors.onTertiaryContainer,
|
||||
error: AppColors.error,
|
||||
onError: AppColors.onError,
|
||||
errorContainer: AppColors.errorContainer,
|
||||
onErrorContainer: AppColors.onErrorContainer,
|
||||
surface: AppColors.surface,
|
||||
onSurface: AppColors.onSurface,
|
||||
surfaceContainerHighest: AppColors.surfaceVariant,
|
||||
onSurfaceVariant: AppColors.onSurfaceVariant,
|
||||
outline: AppColors.outline,
|
||||
outlineVariant: AppColors.outlineVariant,
|
||||
shadow: AppColors.shadow,
|
||||
scrim: AppColors.scrim,
|
||||
inverseSurface: AppColors.inverseSurface,
|
||||
onInverseSurface: AppColors.onInverseSurface,
|
||||
inversePrimary: AppColors.inversePrimary,
|
||||
surfaceTint: AppColors.surfaceTint,
|
||||
),
|
||||
|
||||
// 文本主题
|
||||
textTheme: AppTextStyles.textTheme,
|
||||
|
||||
// AppBar主题
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
centerTitle: true,
|
||||
titleTextStyle: AppTextStyles.titleLarge,
|
||||
toolbarHeight: AppDimensions.appBarHeight,
|
||||
systemOverlayStyle: const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
),
|
||||
),
|
||||
|
||||
// 按钮主题
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
textStyle: AppTextStyles.buttonMedium,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.buttonRadius),
|
||||
),
|
||||
minimumSize: const Size(AppDimensions.buttonMinWidth, AppDimensions.buttonHeight),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.buttonPadding,
|
||||
vertical: AppDimensions.spacingSm,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
textStyle: AppTextStyles.buttonMedium,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.buttonRadius),
|
||||
),
|
||||
minimumSize: const Size(AppDimensions.buttonMinWidth, AppDimensions.buttonHeight),
|
||||
),
|
||||
),
|
||||
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
textStyle: AppTextStyles.buttonMedium,
|
||||
side: const BorderSide(color: AppColors.outline),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.buttonRadius),
|
||||
),
|
||||
minimumSize: const Size(AppDimensions.buttonMinWidth, AppDimensions.buttonHeight),
|
||||
),
|
||||
),
|
||||
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
textStyle: AppTextStyles.buttonMedium,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.buttonRadius),
|
||||
),
|
||||
minimumSize: const Size(AppDimensions.buttonMinWidth, AppDimensions.buttonHeight),
|
||||
),
|
||||
),
|
||||
|
||||
// 输入框主题
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceVariant,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.inputRadius),
|
||||
borderSide: const BorderSide(color: AppColors.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.inputRadius),
|
||||
borderSide: const BorderSide(color: AppColors.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.inputRadius),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.inputRadius),
|
||||
borderSide: const BorderSide(color: AppColors.error),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.inputRadius),
|
||||
borderSide: const BorderSide(color: AppColors.error, width: 2),
|
||||
),
|
||||
labelStyle: AppTextStyles.inputLabel,
|
||||
hintStyle: AppTextStyles.inputHint,
|
||||
errorStyle: AppTextStyles.inputError,
|
||||
contentPadding: const EdgeInsets.all(AppDimensions.inputPadding),
|
||||
),
|
||||
|
||||
// 卡片主题
|
||||
cardTheme: CardThemeData(
|
||||
color: AppColors.surface,
|
||||
elevation: 1,
|
||||
shadowColor: AppColors.shadow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.cardRadius),
|
||||
),
|
||||
margin: const EdgeInsets.all(AppDimensions.cardMargin),
|
||||
),
|
||||
|
||||
// 底部导航栏主题
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: AppColors.surface,
|
||||
selectedItemColor: AppColors.primary,
|
||||
unselectedItemColor: AppColors.onSurfaceVariant,
|
||||
selectedLabelStyle: AppTextStyles.navigationLabel,
|
||||
unselectedLabelStyle: AppTextStyles.navigationLabel,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 8,
|
||||
),
|
||||
|
||||
// 标签栏主题
|
||||
tabBarTheme: TabBarThemeData(
|
||||
labelColor: AppColors.primary,
|
||||
unselectedLabelColor: AppColors.onSurfaceVariant,
|
||||
labelStyle: AppTextStyles.tabLabel,
|
||||
unselectedLabelStyle: AppTextStyles.tabLabel,
|
||||
indicator: const UnderlineTabIndicator(
|
||||
borderSide: BorderSide(color: AppColors.primary, width: 2),
|
||||
),
|
||||
),
|
||||
|
||||
// 芯片主题
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: AppColors.surfaceVariant,
|
||||
selectedColor: AppColors.primaryContainer,
|
||||
disabledColor: AppColors.surfaceVariant.withValues(alpha: 0.5),
|
||||
labelStyle: AppTextStyles.labelMedium,
|
||||
secondaryLabelStyle: AppTextStyles.labelMedium,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.chipRadius),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.spacingSm,
|
||||
vertical: AppDimensions.spacingXs,
|
||||
),
|
||||
),
|
||||
|
||||
// 对话框主题
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.dialogRadius),
|
||||
),
|
||||
titleTextStyle: AppTextStyles.headlineSmall,
|
||||
contentTextStyle: AppTextStyles.bodyMedium,
|
||||
),
|
||||
|
||||
// 提示条主题
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: AppColors.inverseSurface,
|
||||
contentTextStyle: AppTextStyles.bodyMedium.copyWith(
|
||||
color: AppColors.onInverseSurface,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusSm),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
|
||||
// 浮动操作按钮主题
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
elevation: 6,
|
||||
focusElevation: 8,
|
||||
hoverElevation: 8,
|
||||
highlightElevation: 12,
|
||||
),
|
||||
|
||||
// 分割线主题
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.outline,
|
||||
thickness: AppDimensions.dividerHeight,
|
||||
space: AppDimensions.dividerHeight,
|
||||
),
|
||||
|
||||
// 列表项主题
|
||||
listTileTheme: ListTileThemeData(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.listItemPadding,
|
||||
vertical: AppDimensions.spacingXs,
|
||||
),
|
||||
titleTextStyle: AppTextStyles.listTitle,
|
||||
subtitleTextStyle: AppTextStyles.listSubtitle,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusSm),
|
||||
),
|
||||
),
|
||||
|
||||
// 开关主题
|
||||
switchTheme: SwitchThemeData(
|
||||
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.onPrimary;
|
||||
}
|
||||
return AppColors.outline;
|
||||
}),
|
||||
trackColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primary;
|
||||
}
|
||||
return AppColors.surfaceVariant;
|
||||
}),
|
||||
),
|
||||
|
||||
// 复选框主题
|
||||
checkboxTheme: CheckboxThemeData(
|
||||
fillColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primary;
|
||||
}
|
||||
return Colors.transparent;
|
||||
}),
|
||||
checkColor: WidgetStateProperty.all(AppColors.onPrimary),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusXs),
|
||||
),
|
||||
),
|
||||
|
||||
// 单选按钮主题
|
||||
radioTheme: RadioThemeData(
|
||||
fillColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return AppColors.primary;
|
||||
}
|
||||
return AppColors.onSurfaceVariant;
|
||||
}),
|
||||
),
|
||||
|
||||
// 滑块主题
|
||||
sliderTheme: const SliderThemeData(
|
||||
activeTrackColor: AppColors.primary,
|
||||
inactiveTrackColor: AppColors.surfaceVariant,
|
||||
thumbColor: AppColors.primary,
|
||||
overlayColor: AppColors.primaryContainer,
|
||||
valueIndicatorColor: AppColors.primary,
|
||||
),
|
||||
|
||||
// 进度指示器主题
|
||||
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||
color: AppColors.primary,
|
||||
linearTrackColor: AppColors.surfaceVariant,
|
||||
circularTrackColor: AppColors.surfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 深色主题 ============
|
||||
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
|
||||
// 禁用Google Fonts,使用系统默认字体
|
||||
fontFamily: null, // 不指定字体,使用系统默认
|
||||
|
||||
// 颜色方案
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: AppColors.primaryDark,
|
||||
onPrimary: AppColors.onPrimaryDark,
|
||||
primaryContainer: AppColors.primaryContainerDark,
|
||||
onPrimaryContainer: AppColors.onPrimaryContainerDark,
|
||||
secondary: AppColors.secondaryDark,
|
||||
onSecondary: AppColors.onSecondaryDark,
|
||||
secondaryContainer: AppColors.secondaryContainerDark,
|
||||
onSecondaryContainer: AppColors.onSecondaryContainerDark,
|
||||
tertiary: AppColors.tertiaryDark,
|
||||
onTertiary: AppColors.onTertiaryDark,
|
||||
tertiaryContainer: AppColors.tertiaryContainerDark,
|
||||
onTertiaryContainer: AppColors.onTertiaryContainerDark,
|
||||
error: AppColors.errorDark,
|
||||
onError: AppColors.onErrorDark,
|
||||
errorContainer: AppColors.errorContainerDark,
|
||||
onErrorContainer: AppColors.onErrorContainerDark,
|
||||
surface: AppColors.surfaceDark,
|
||||
onSurface: AppColors.onSurfaceDark,
|
||||
surfaceContainerHighest: AppColors.surfaceVariantDark,
|
||||
onSurfaceVariant: AppColors.onSurfaceVariantDark,
|
||||
outline: AppColors.outlineDark,
|
||||
outlineVariant: AppColors.outlineVariantDark,
|
||||
shadow: AppColors.shadowDark,
|
||||
scrim: AppColors.scrimDark,
|
||||
inverseSurface: AppColors.inverseSurfaceDark,
|
||||
onInverseSurface: AppColors.onInverseSurfaceDark,
|
||||
inversePrimary: AppColors.inversePrimaryDark,
|
||||
surfaceTint: AppColors.surfaceTintDark,
|
||||
),
|
||||
|
||||
// 文本主题(深色适配)
|
||||
textTheme: AppTextStyles.textTheme.apply(
|
||||
bodyColor: AppColors.onSurfaceDark,
|
||||
displayColor: AppColors.onSurfaceDark,
|
||||
),
|
||||
|
||||
// AppBar主题
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: AppColors.surfaceDark,
|
||||
foregroundColor: AppColors.onSurfaceDark,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
centerTitle: true,
|
||||
titleTextStyle: AppTextStyles.titleLarge.copyWith(
|
||||
color: AppColors.onSurfaceDark,
|
||||
),
|
||||
toolbarHeight: AppDimensions.appBarHeight,
|
||||
systemOverlayStyle: const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
),
|
||||
),
|
||||
|
||||
// 其他组件主题配置与亮色主题类似,但使用深色颜色
|
||||
// 为了简洁,这里省略了重复的配置
|
||||
// 实际项目中应该完整配置所有组件的深色主题
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 主题扩展 ============
|
||||
|
||||
/// 获取当前主题的学习模块颜色
|
||||
static Map<String, Color> getLearningColors(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return {
|
||||
'vocabulary': isDark ? AppColors.vocabularyDark : AppColors.vocabulary,
|
||||
'listening': isDark ? AppColors.listeningDark : AppColors.listening,
|
||||
'reading': isDark ? AppColors.readingDark : AppColors.reading,
|
||||
'writing': isDark ? AppColors.writingDark : AppColors.writing,
|
||||
'speaking': isDark ? AppColors.speakingDark : AppColors.speaking,
|
||||
};
|
||||
}
|
||||
|
||||
/// 获取等级颜色
|
||||
static Color getLevelColor(String level, {bool isDark = false}) {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'beginner':
|
||||
return isDark ? AppColors.beginnerDark : AppColors.beginner;
|
||||
case 'intermediate':
|
||||
return isDark ? AppColors.intermediateDark : AppColors.intermediate;
|
||||
case 'advanced':
|
||||
return isDark ? AppColors.advancedDark : AppColors.advanced;
|
||||
default:
|
||||
return isDark ? AppColors.primaryDark : AppColors.primary;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取进度颜色
|
||||
static Color getProgressColor(double progress, {bool isDark = false}) {
|
||||
if (progress < 0.3) {
|
||||
return isDark ? AppColors.progressLowDark : AppColors.progressLow;
|
||||
} else if (progress < 0.7) {
|
||||
return isDark ? AppColors.progressMediumDark : AppColors.progressMedium;
|
||||
} else {
|
||||
return isDark ? AppColors.progressHighDark : AppColors.progressHigh;
|
||||
}
|
||||
}
|
||||
}
|
||||
141
client/lib/core/utils/exceptions.dart
Normal file
141
client/lib/core/utils/exceptions.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
/// 应用异常基类
|
||||
abstract class AppException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
final dynamic details;
|
||||
|
||||
const AppException(this.message, {this.code, this.details});
|
||||
|
||||
@override
|
||||
String toString() => 'AppException: $message';
|
||||
}
|
||||
|
||||
/// 网络异常
|
||||
class NetworkException extends AppException {
|
||||
const NetworkException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkException: $message';
|
||||
}
|
||||
|
||||
/// 认证异常
|
||||
class AuthException extends AppException {
|
||||
const AuthException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'AuthException: $message';
|
||||
}
|
||||
|
||||
/// 验证异常
|
||||
class ValidationException extends AppException {
|
||||
const ValidationException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'ValidationException: $message';
|
||||
}
|
||||
|
||||
/// 服务器异常
|
||||
class ServerException extends AppException {
|
||||
const ServerException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'ServerException: $message';
|
||||
}
|
||||
|
||||
/// 缓存异常
|
||||
class CacheException extends AppException {
|
||||
const CacheException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'CacheException: $message';
|
||||
}
|
||||
|
||||
/// 文件异常
|
||||
class FileException extends AppException {
|
||||
const FileException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'FileException: $message';
|
||||
}
|
||||
|
||||
/// 权限异常
|
||||
class PermissionException extends AppException {
|
||||
const PermissionException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'PermissionException: $message';
|
||||
}
|
||||
|
||||
/// 业务逻辑异常
|
||||
class BusinessException extends AppException {
|
||||
const BusinessException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'BusinessException: $message';
|
||||
}
|
||||
|
||||
/// 超时异常
|
||||
class TimeoutException extends AppException {
|
||||
const TimeoutException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'TimeoutException: $message';
|
||||
}
|
||||
|
||||
/// 数据解析异常
|
||||
class ParseException extends AppException {
|
||||
const ParseException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'ParseException: $message';
|
||||
}
|
||||
|
||||
/// 通用应用异常
|
||||
class GeneralAppException extends AppException {
|
||||
const GeneralAppException(super.message, {super.code, super.details});
|
||||
|
||||
@override
|
||||
String toString() => 'GeneralAppException: $message';
|
||||
}
|
||||
|
||||
/// 异常处理工具类
|
||||
class ExceptionHandler {
|
||||
/// 处理异常并返回用户友好的错误信息
|
||||
static String getErrorMessage(dynamic error) {
|
||||
if (error is AppException) {
|
||||
return error.message;
|
||||
} else if (error is Exception) {
|
||||
return '发生了未知错误,请稍后重试';
|
||||
} else {
|
||||
return '系统错误,请联系客服';
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录异常
|
||||
static void logException(dynamic error, {StackTrace? stackTrace}) {
|
||||
// 这里可以集成日志记录服务,如Firebase Crashlytics
|
||||
print('Exception: $error');
|
||||
if (stackTrace != null) {
|
||||
print('StackTrace: $stackTrace');
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为网络相关异常
|
||||
static bool isNetworkError(dynamic error) {
|
||||
return error is NetworkException ||
|
||||
error is TimeoutException ||
|
||||
(error is AppException && error.code?.contains('network') == true);
|
||||
}
|
||||
|
||||
/// 判断是否为认证相关异常
|
||||
static bool isAuthError(dynamic error) {
|
||||
return error is AuthException ||
|
||||
(error is AppException && error.code?.contains('auth') == true);
|
||||
}
|
||||
|
||||
/// 判断是否为验证相关异常
|
||||
static bool isValidationError(dynamic error) {
|
||||
return error is ValidationException ||
|
||||
(error is AppException && error.code?.contains('validation') == true);
|
||||
}
|
||||
}
|
||||
185
client/lib/core/utils/message_utils.dart
Normal file
185
client/lib/core/utils/message_utils.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局消息提示工具类
|
||||
///
|
||||
/// 特性:
|
||||
/// - 新消息自动取消之前的提示
|
||||
/// - 统一的样式和动画
|
||||
/// - 缩短显示时间,避免堆积
|
||||
class MessageUtils {
|
||||
MessageUtils._();
|
||||
|
||||
/// 显示普通消息
|
||||
static void showMessage(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration duration = const Duration(seconds: 2),
|
||||
SnackBarAction? action,
|
||||
}) {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
// 清除之前的所有SnackBar
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
duration: duration,
|
||||
action: action,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示成功消息
|
||||
static void showSuccess(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration duration = const Duration(seconds: 2),
|
||||
}) {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
duration: duration,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示错误消息
|
||||
static void showError(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration duration = const Duration(seconds: 2),
|
||||
}) {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
duration: duration,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示警告消息
|
||||
static void showWarning(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration duration = const Duration(seconds: 2),
|
||||
}) {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: duration,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示信息消息
|
||||
static void showInfo(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration duration = const Duration(seconds: 2),
|
||||
}) {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.info, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.blue,
|
||||
duration: duration,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示加载消息(不自动消失)
|
||||
static void showLoading(
|
||||
BuildContext context,
|
||||
String message,
|
||||
) {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
scaffoldMessenger.clearSnackBars();
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
duration: const Duration(days: 1), // 长时间显示
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 隐藏所有消息
|
||||
static void hideAll(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
}
|
||||
}
|
||||
148
client/lib/core/utils/responsive_utils.dart
Normal file
148
client/lib/core/utils/responsive_utils.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 响应式布局工具类
|
||||
class ResponsiveUtils {
|
||||
// 断点定义
|
||||
static const double mobileBreakpoint = 600;
|
||||
static const double tabletBreakpoint = 900;
|
||||
static const double desktopBreakpoint = 1200;
|
||||
|
||||
/// 判断是否为移动端
|
||||
static bool isMobile(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width < mobileBreakpoint;
|
||||
}
|
||||
|
||||
/// 判断是否为平板端
|
||||
static bool isTablet(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return width >= mobileBreakpoint && width < desktopBreakpoint;
|
||||
}
|
||||
|
||||
/// 判断是否为桌面端
|
||||
static bool isDesktop(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width >= desktopBreakpoint;
|
||||
}
|
||||
|
||||
/// 获取网格列数
|
||||
static int getGridColumns(BuildContext context, {
|
||||
int mobileColumns = 1,
|
||||
int tabletColumns = 2,
|
||||
int desktopColumns = 3,
|
||||
}) {
|
||||
if (isMobile(context)) return mobileColumns;
|
||||
if (isTablet(context)) return tabletColumns;
|
||||
return desktopColumns;
|
||||
}
|
||||
|
||||
/// 获取响应式边距
|
||||
static EdgeInsets getResponsivePadding(BuildContext context, {
|
||||
EdgeInsets? mobile,
|
||||
EdgeInsets? tablet,
|
||||
EdgeInsets? desktop,
|
||||
}) {
|
||||
if (isMobile(context)) return mobile ?? const EdgeInsets.all(16);
|
||||
if (isTablet(context)) return tablet ?? const EdgeInsets.all(20);
|
||||
return desktop ?? const EdgeInsets.all(24);
|
||||
}
|
||||
|
||||
/// 获取响应式字体大小
|
||||
static double getResponsiveFontSize(BuildContext context, {
|
||||
double mobileSize = 14,
|
||||
double tabletSize = 16,
|
||||
double desktopSize = 18,
|
||||
}) {
|
||||
if (isMobile(context)) return mobileSize;
|
||||
if (isTablet(context)) return tabletSize;
|
||||
return desktopSize;
|
||||
}
|
||||
|
||||
/// 获取分类卡片宽度
|
||||
static double getCategoryCardWidth(BuildContext context) {
|
||||
if (isMobile(context)) return 80;
|
||||
if (isTablet(context)) return 100;
|
||||
return 120;
|
||||
}
|
||||
|
||||
/// 获取分类卡片高度
|
||||
static double getCategoryCardHeight(BuildContext context) {
|
||||
if (isMobile(context)) return 90;
|
||||
if (isTablet(context)) return 100;
|
||||
return 110;
|
||||
}
|
||||
|
||||
/// 根据屏幕宽度返回不同的值
|
||||
static T getValueForScreenSize<T>(
|
||||
BuildContext context, {
|
||||
required T mobile,
|
||||
T? tablet,
|
||||
T? desktop,
|
||||
}) {
|
||||
if (isMobile(context)) return mobile;
|
||||
if (isTablet(context)) return tablet ?? mobile;
|
||||
return desktop ?? tablet ?? mobile;
|
||||
}
|
||||
}
|
||||
|
||||
/// 响应式布局构建器
|
||||
class ResponsiveBuilder extends StatelessWidget {
|
||||
final Widget Function(BuildContext context, bool isMobile, bool isTablet, bool isDesktop) builder;
|
||||
|
||||
const ResponsiveBuilder({
|
||||
super.key,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = ResponsiveUtils.isMobile(context);
|
||||
final isTablet = ResponsiveUtils.isTablet(context);
|
||||
final isDesktop = ResponsiveUtils.isDesktop(context);
|
||||
|
||||
return builder(context, isMobile, isTablet, isDesktop);
|
||||
}
|
||||
}
|
||||
|
||||
/// 响应式网格视图
|
||||
class ResponsiveGridView extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final int mobileColumns;
|
||||
final int tabletColumns;
|
||||
final int desktopColumns;
|
||||
final double spacing;
|
||||
final EdgeInsets padding;
|
||||
final double childAspectRatio;
|
||||
|
||||
const ResponsiveGridView({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.mobileColumns = 1,
|
||||
this.tabletColumns = 2,
|
||||
this.desktopColumns = 3,
|
||||
this.spacing = 16,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
this.childAspectRatio = 1.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final columns = ResponsiveUtils.getGridColumns(
|
||||
context,
|
||||
mobileColumns: mobileColumns,
|
||||
tabletColumns: tabletColumns,
|
||||
desktopColumns: desktopColumns,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: GridView.count(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: spacing,
|
||||
mainAxisSpacing: spacing,
|
||||
childAspectRatio: childAspectRatio,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
194
client/lib/core/widgets/custom_button.dart
Normal file
194
client/lib/core/widgets/custom_button.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_text_styles.dart';
|
||||
import '../theme/app_dimensions.dart';
|
||||
|
||||
/// 自定义按钮组件
|
||||
class CustomButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
final bool isOutlined;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
final IconData? icon;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const CustomButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.isOutlined = false,
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
this.icon,
|
||||
this.width,
|
||||
this.height,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveBackgroundColor = backgroundColor ??
|
||||
(isOutlined ? Colors.transparent : AppColors.primary);
|
||||
final effectiveTextColor = textColor ??
|
||||
(isOutlined ? AppColors.primary : AppColors.onPrimary);
|
||||
final effectivePadding = padding ?? EdgeInsets.symmetric(
|
||||
vertical: AppDimensions.spacingMd,
|
||||
horizontal: AppDimensions.spacingLg,
|
||||
);
|
||||
|
||||
Widget buttonChild = isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(effectiveTextColor),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
color: effectiveTextColor,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: AppDimensions.spacingSm),
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: AppTextStyles.bodyLarge.copyWith(
|
||||
color: effectiveTextColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (isOutlined) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: OutlinedButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(
|
||||
color: onPressed != null ? AppColors.primary : AppColors.onSurface.withOpacity(0.3),
|
||||
width: 1.5,
|
||||
),
|
||||
padding: effectivePadding,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
),
|
||||
child: buttonChild,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: effectiveBackgroundColor,
|
||||
foregroundColor: effectiveTextColor,
|
||||
padding: effectivePadding,
|
||||
elevation: 2,
|
||||
shadowColor: AppColors.primary.withOpacity(0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
disabledBackgroundColor: AppColors.onSurface.withOpacity(0.12),
|
||||
disabledForegroundColor: AppColors.onSurface.withOpacity(0.38),
|
||||
),
|
||||
child: buttonChild,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 图标按钮组件
|
||||
class CustomIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
final Color? backgroundColor;
|
||||
final Color? iconColor;
|
||||
final double? size;
|
||||
final String? tooltip;
|
||||
final bool isLoading;
|
||||
|
||||
const CustomIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.onPressed,
|
||||
this.backgroundColor,
|
||||
this.iconColor,
|
||||
this.size,
|
||||
this.tooltip,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveSize = size ?? 48.0;
|
||||
final effectiveBackgroundColor = backgroundColor ?? AppColors.primary;
|
||||
final effectiveIconColor = iconColor ?? AppColors.onPrimary;
|
||||
|
||||
Widget iconWidget = isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(effectiveIconColor),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
icon,
|
||||
color: effectiveIconColor,
|
||||
size: effectiveSize * 0.5,
|
||||
);
|
||||
|
||||
Widget button = Container(
|
||||
width: effectiveSize,
|
||||
height: effectiveSize,
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(effectiveSize / 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: isLoading ? null : onPressed,
|
||||
borderRadius: BorderRadius.circular(effectiveSize / 2),
|
||||
child: Center(child: iconWidget),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (tooltip != null) {
|
||||
return Tooltip(
|
||||
message: tooltip!,
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
}
|
||||
265
client/lib/core/widgets/custom_text_field.dart
Normal file
265
client/lib/core/widgets/custom_text_field.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_text_styles.dart';
|
||||
import '../theme/app_dimensions.dart';
|
||||
|
||||
/// 自定义文本输入框组件
|
||||
class CustomTextField extends StatefulWidget {
|
||||
final TextEditingController? controller;
|
||||
final String? labelText;
|
||||
final String? hintText;
|
||||
final String? helperText;
|
||||
final String? errorText;
|
||||
final IconData? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final bool obscureText;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final int? maxLines;
|
||||
final int? minLines;
|
||||
final int? maxLength;
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
final bool autofocus;
|
||||
final String? Function(String?)? validator;
|
||||
final void Function(String)? onChanged;
|
||||
final void Function()? onTap;
|
||||
final void Function(String)? onSubmitted;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final FocusNode? focusNode;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final TextStyle? textStyle;
|
||||
final TextStyle? labelStyle;
|
||||
final TextStyle? hintStyle;
|
||||
final Color? fillColor;
|
||||
final Color? borderColor;
|
||||
final Color? focusedBorderColor;
|
||||
final Color? errorBorderColor;
|
||||
final double? borderRadius;
|
||||
final bool filled;
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.helperText,
|
||||
this.errorText,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.obscureText = false,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.maxLines = 1,
|
||||
this.minLines,
|
||||
this.maxLength,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.autofocus = false,
|
||||
this.validator,
|
||||
this.onChanged,
|
||||
this.onTap,
|
||||
this.onSubmitted,
|
||||
this.inputFormatters,
|
||||
this.focusNode,
|
||||
this.contentPadding,
|
||||
this.textStyle,
|
||||
this.labelStyle,
|
||||
this.hintStyle,
|
||||
this.fillColor,
|
||||
this.borderColor,
|
||||
this.focusedBorderColor,
|
||||
this.errorBorderColor,
|
||||
this.borderRadius,
|
||||
this.filled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomTextField> createState() => _CustomTextFieldState();
|
||||
}
|
||||
|
||||
class _CustomTextFieldState extends State<CustomTextField> {
|
||||
late FocusNode _focusNode;
|
||||
bool _isFocused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = widget.focusNode ?? FocusNode();
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.focusNode == null) {
|
||||
_focusNode.dispose();
|
||||
} else {
|
||||
_focusNode.removeListener(_onFocusChange);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
setState(() {
|
||||
_isFocused = _focusNode.hasFocus;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveBorderRadius = widget.borderRadius ?? 8.0;
|
||||
final effectiveFillColor = widget.fillColor ?? AppColors.surface;
|
||||
final effectiveBorderColor = widget.borderColor ?? AppColors.onSurface.withOpacity(0.3);
|
||||
final effectiveFocusedBorderColor = widget.focusedBorderColor ?? AppColors.primary;
|
||||
final effectiveErrorBorderColor = widget.errorBorderColor ?? AppColors.error;
|
||||
|
||||
final effectiveContentPadding = widget.contentPadding ?? EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.spacingMd,
|
||||
vertical: AppDimensions.spacingMd,
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.labelText != null) ...[
|
||||
Text(
|
||||
widget.labelText!,
|
||||
style: widget.labelStyle ?? AppTextStyles.bodyMedium.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: AppDimensions.spacingSm),
|
||||
],
|
||||
|
||||
TextFormField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
obscureText: widget.obscureText,
|
||||
keyboardType: widget.keyboardType,
|
||||
textInputAction: widget.textInputAction,
|
||||
maxLines: widget.maxLines,
|
||||
minLines: widget.minLines,
|
||||
maxLength: widget.maxLength,
|
||||
enabled: widget.enabled,
|
||||
readOnly: widget.readOnly,
|
||||
autofocus: widget.autofocus,
|
||||
validator: widget.validator,
|
||||
onChanged: widget.onChanged,
|
||||
onTap: widget.onTap,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
style: widget.textStyle ?? AppTextStyles.bodyLarge.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
hintStyle: widget.hintStyle ?? AppTextStyles.bodyLarge.copyWith(
|
||||
color: AppColors.onSurface.withOpacity(0.6),
|
||||
),
|
||||
helperText: widget.helperText,
|
||||
errorText: widget.errorText,
|
||||
prefixIcon: widget.prefixIcon != null
|
||||
? Icon(
|
||||
widget.prefixIcon,
|
||||
color: _isFocused ? effectiveFocusedBorderColor : AppColors.onSurface.withOpacity(0.6),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: widget.suffixIcon,
|
||||
filled: widget.filled,
|
||||
fillColor: effectiveFillColor,
|
||||
contentPadding: effectiveContentPadding,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(effectiveBorderRadius),
|
||||
borderSide: BorderSide(
|
||||
color: effectiveBorderColor,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(effectiveBorderRadius),
|
||||
borderSide: BorderSide(
|
||||
color: effectiveBorderColor,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(effectiveBorderRadius),
|
||||
borderSide: BorderSide(
|
||||
color: effectiveFocusedBorderColor,
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(effectiveBorderRadius),
|
||||
borderSide: BorderSide(
|
||||
color: effectiveErrorBorderColor,
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(effectiveBorderRadius),
|
||||
borderSide: BorderSide(
|
||||
color: effectiveErrorBorderColor,
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(effectiveBorderRadius),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.onSurface.withOpacity(0.12),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索输入框组件
|
||||
class SearchTextField extends StatelessWidget {
|
||||
final TextEditingController? controller;
|
||||
final String? hintText;
|
||||
final void Function(String)? onChanged;
|
||||
final void Function(String)? onSubmitted;
|
||||
final VoidCallback? onClear;
|
||||
final bool autofocus;
|
||||
final FocusNode? focusNode;
|
||||
|
||||
const SearchTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.hintText,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.onClear,
|
||||
this.autofocus = false,
|
||||
this.focusNode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomTextField(
|
||||
controller: controller,
|
||||
hintText: hintText ?? '搜索...',
|
||||
prefixIcon: Icons.search,
|
||||
suffixIcon: controller?.text.isNotEmpty == true
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
controller?.clear();
|
||||
onClear?.call();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
autofocus: autofocus,
|
||||
focusNode: focusNode,
|
||||
textInputAction: TextInputAction.search,
|
||||
);
|
||||
}
|
||||
}
|
||||
106
client/lib/core/widgets/not_found_screen.dart
Normal file
106
client/lib/core/widgets/not_found_screen.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_text_styles.dart';
|
||||
import '../theme/app_dimensions.dart';
|
||||
|
||||
/// 404页面 - 页面未找到
|
||||
class NotFoundScreen extends StatelessWidget {
|
||||
final String? routeName;
|
||||
|
||||
const NotFoundScreen({
|
||||
super.key,
|
||||
this.routeName,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('页面未找到'),
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.onSurface,
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(AppDimensions.pagePadding),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 404图标
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: AppDimensions.iconXxl * 2,
|
||||
color: AppColors.outline,
|
||||
),
|
||||
|
||||
SizedBox(height: AppDimensions.spacingXl),
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
'页面未找到',
|
||||
style: AppTextStyles.headlineLarge.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
SizedBox(height: AppDimensions.spacingMd),
|
||||
|
||||
// 描述
|
||||
Text(
|
||||
routeName != null
|
||||
? '路由 "$routeName" 不存在'
|
||||
: '您访问的页面不存在或已被移除',
|
||||
style: AppTextStyles.bodyLarge.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
SizedBox(height: AppDimensions.spacingXxl),
|
||||
|
||||
// 返回按钮
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
} else {
|
||||
Navigator.of(context).pushReplacementNamed('/');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('返回'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.spacingXl,
|
||||
vertical: AppDimensions.spacingMd,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: AppDimensions.spacingMd),
|
||||
|
||||
// 回到首页按钮
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
'/',
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'回到首页',
|
||||
style: AppTextStyles.labelLarge.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user