init
This commit is contained in:
260
client/lib/shared/models/api_response.dart
Normal file
260
client/lib/shared/models/api_response.dart
Normal file
@@ -0,0 +1,260 @@
|
||||
/// API响应基础模型
|
||||
class ApiResponse<T> {
|
||||
final bool success;
|
||||
final String message;
|
||||
final T? data;
|
||||
final String? error;
|
||||
final int? code;
|
||||
final Map<String, dynamic>? meta;
|
||||
|
||||
const ApiResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
this.data,
|
||||
this.error,
|
||||
this.code,
|
||||
this.meta,
|
||||
});
|
||||
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(dynamic)? fromJsonT,
|
||||
) {
|
||||
return ApiResponse<T>(
|
||||
success: json['success'] as bool? ?? false,
|
||||
message: json['message'] as String? ?? '',
|
||||
data: json['data'] != null && fromJsonT != null
|
||||
? fromJsonT(json['data'])
|
||||
: json['data'] as T?,
|
||||
error: json['error'] as String?,
|
||||
code: json['code'] as int?,
|
||||
meta: json['meta'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'success': success,
|
||||
'message': message,
|
||||
'data': data,
|
||||
'error': error,
|
||||
'code': code,
|
||||
'meta': meta,
|
||||
};
|
||||
}
|
||||
|
||||
/// 成功响应
|
||||
factory ApiResponse.success({
|
||||
required String message,
|
||||
T? data,
|
||||
Map<String, dynamic>? meta,
|
||||
}) {
|
||||
return ApiResponse<T>(
|
||||
success: true,
|
||||
message: message,
|
||||
data: data,
|
||||
meta: meta,
|
||||
);
|
||||
}
|
||||
|
||||
/// 失败响应
|
||||
factory ApiResponse.failure({
|
||||
required String message,
|
||||
String? error,
|
||||
int? code,
|
||||
Map<String, dynamic>? meta,
|
||||
}) {
|
||||
return ApiResponse<T>(
|
||||
success: false,
|
||||
message: message,
|
||||
error: error,
|
||||
code: code,
|
||||
meta: meta,
|
||||
);
|
||||
}
|
||||
|
||||
/// 是否成功
|
||||
bool get isSuccess => success;
|
||||
|
||||
/// 是否失败
|
||||
bool get isFailure => !success;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ApiResponse(success: $success, message: $message, data: $data, error: $error)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页响应模型
|
||||
class PaginatedResponse<T> {
|
||||
final List<T> data;
|
||||
final PaginationMeta pagination;
|
||||
|
||||
const PaginatedResponse({
|
||||
required this.data,
|
||||
required this.pagination,
|
||||
});
|
||||
|
||||
factory PaginatedResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Map<String, dynamic>) fromJsonT,
|
||||
) {
|
||||
return PaginatedResponse<T>(
|
||||
data: (json['data'] as List)
|
||||
.map((e) => fromJsonT(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
pagination: PaginationMeta.fromJson(json['pagination'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'data': data,
|
||||
'pagination': pagination.toJson(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页元数据模型
|
||||
class PaginationMeta {
|
||||
final int currentPage;
|
||||
final int totalPages;
|
||||
final int totalItems;
|
||||
final int itemsPerPage;
|
||||
final bool hasNextPage;
|
||||
final bool hasPreviousPage;
|
||||
|
||||
const PaginationMeta({
|
||||
required this.currentPage,
|
||||
required this.totalPages,
|
||||
required this.totalItems,
|
||||
required this.itemsPerPage,
|
||||
required this.hasNextPage,
|
||||
required this.hasPreviousPage,
|
||||
});
|
||||
|
||||
factory PaginationMeta.fromJson(Map<String, dynamic> json) {
|
||||
return PaginationMeta(
|
||||
currentPage: json['current_page'] as int,
|
||||
totalPages: json['total_pages'] as int,
|
||||
totalItems: json['total_items'] as int,
|
||||
itemsPerPage: json['items_per_page'] as int,
|
||||
hasNextPage: json['has_next_page'] as bool,
|
||||
hasPreviousPage: json['has_previous_page'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'current_page': currentPage,
|
||||
'total_pages': totalPages,
|
||||
'total_items': totalItems,
|
||||
'items_per_page': itemsPerPage,
|
||||
'has_next_page': hasNextPage,
|
||||
'has_previous_page': hasPreviousPage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证响应模型
|
||||
class AuthResponse {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final String tokenType;
|
||||
final int expiresIn;
|
||||
final Map<String, dynamic>? user;
|
||||
|
||||
const AuthResponse({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.tokenType,
|
||||
required this.expiresIn,
|
||||
this.user,
|
||||
});
|
||||
|
||||
factory AuthResponse.fromJson(Map<String, dynamic> json) {
|
||||
return AuthResponse(
|
||||
accessToken: json['access_token'] as String,
|
||||
refreshToken: json['refresh_token'] as String,
|
||||
tokenType: json['token_type'] as String? ?? 'Bearer',
|
||||
expiresIn: json['expires_in'] as int,
|
||||
user: json['user'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'access_token': accessToken,
|
||||
'refresh_token': refreshToken,
|
||||
'token_type': tokenType,
|
||||
'expires_in': expiresIn,
|
||||
'user': user,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误响应模型
|
||||
class ErrorResponse {
|
||||
final String message;
|
||||
final String? code;
|
||||
final List<ValidationError>? validationErrors;
|
||||
final Map<String, dynamic>? details;
|
||||
|
||||
const ErrorResponse({
|
||||
required this.message,
|
||||
this.code,
|
||||
this.validationErrors,
|
||||
this.details,
|
||||
});
|
||||
|
||||
factory ErrorResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ErrorResponse(
|
||||
message: json['message'] as String,
|
||||
code: json['code'] as String?,
|
||||
validationErrors: json['validation_errors'] != null
|
||||
? (json['validation_errors'] as List)
|
||||
.map((e) => ValidationError.fromJson(e as Map<String, dynamic>))
|
||||
.toList()
|
||||
: null,
|
||||
details: json['details'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'message': message,
|
||||
'code': code,
|
||||
'validation_errors': validationErrors?.map((e) => e.toJson()).toList(),
|
||||
'details': details,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证错误模型
|
||||
class ValidationError {
|
||||
final String field;
|
||||
final String message;
|
||||
final String? code;
|
||||
|
||||
const ValidationError({
|
||||
required this.field,
|
||||
required this.message,
|
||||
this.code,
|
||||
});
|
||||
|
||||
factory ValidationError.fromJson(Map<String, dynamic> json) {
|
||||
return ValidationError(
|
||||
field: json['field'] as String,
|
||||
message: json['message'] as String,
|
||||
code: json['code'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'field': field,
|
||||
'message': message,
|
||||
'code': code,
|
||||
};
|
||||
}
|
||||
}
|
||||
90
client/lib/shared/models/notification_model.dart
Normal file
90
client/lib/shared/models/notification_model.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'notification_model.g.dart';
|
||||
|
||||
/// 通知模型
|
||||
@JsonSerializable()
|
||||
class NotificationModel {
|
||||
final int id;
|
||||
|
||||
@JsonKey(name: 'user_id')
|
||||
final int userId;
|
||||
|
||||
final String type;
|
||||
final String title;
|
||||
final String content;
|
||||
final String? link;
|
||||
|
||||
@JsonKey(name: 'is_read')
|
||||
final bool isRead;
|
||||
|
||||
final int priority;
|
||||
|
||||
@JsonKey(name: 'created_at')
|
||||
final DateTime createdAt;
|
||||
|
||||
@JsonKey(name: 'read_at')
|
||||
final DateTime? readAt;
|
||||
|
||||
const NotificationModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.link,
|
||||
required this.isRead,
|
||||
required this.priority,
|
||||
required this.createdAt,
|
||||
this.readAt,
|
||||
});
|
||||
|
||||
factory NotificationModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$NotificationModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$NotificationModelToJson(this);
|
||||
|
||||
/// 通知类型
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case 'system':
|
||||
return '系统通知';
|
||||
case 'learning':
|
||||
return '学习提醒';
|
||||
case 'achievement':
|
||||
return '成就通知';
|
||||
default:
|
||||
return '通知';
|
||||
}
|
||||
}
|
||||
|
||||
/// 优先级
|
||||
String get priorityLabel {
|
||||
switch (priority) {
|
||||
case 0:
|
||||
return '普通';
|
||||
case 1:
|
||||
return '重要';
|
||||
case 2:
|
||||
return '紧急';
|
||||
default:
|
||||
return '普通';
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化时间
|
||||
String get timeAgo {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(createdAt);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays}天前';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
}
|
||||
37
client/lib/shared/models/notification_model.g.dart
Normal file
37
client/lib/shared/models/notification_model.g.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'notification_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
NotificationModel _$NotificationModelFromJson(Map<String, dynamic> json) =>
|
||||
NotificationModel(
|
||||
id: (json['id'] as num).toInt(),
|
||||
userId: (json['user_id'] as num).toInt(),
|
||||
type: json['type'] as String,
|
||||
title: json['title'] as String,
|
||||
content: json['content'] as String,
|
||||
link: json['link'] as String?,
|
||||
isRead: json['is_read'] as bool,
|
||||
priority: (json['priority'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
readAt: json['read_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['read_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$NotificationModelToJson(NotificationModel instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'user_id': instance.userId,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'content': instance.content,
|
||||
'link': instance.link,
|
||||
'is_read': instance.isRead,
|
||||
'priority': instance.priority,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'read_at': instance.readAt?.toIso8601String(),
|
||||
};
|
||||
220
client/lib/shared/models/user_model.dart
Normal file
220
client/lib/shared/models/user_model.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'user_model.g.dart';
|
||||
|
||||
/// 用户模型
|
||||
@JsonSerializable()
|
||||
class UserModel {
|
||||
@JsonKey(name: 'user_id')
|
||||
final int userId;
|
||||
|
||||
final String username;
|
||||
final String email;
|
||||
final String? nickname;
|
||||
final String? avatar;
|
||||
final String? phone;
|
||||
final DateTime? birthday;
|
||||
final String? gender;
|
||||
final String? bio;
|
||||
|
||||
@JsonKey(name: 'learning_level')
|
||||
final String learningLevel;
|
||||
|
||||
@JsonKey(name: 'target_language')
|
||||
final String targetLanguage;
|
||||
|
||||
@JsonKey(name: 'native_language')
|
||||
final String nativeLanguage;
|
||||
|
||||
@JsonKey(name: 'daily_goal')
|
||||
final int dailyGoal;
|
||||
|
||||
@JsonKey(name: 'study_streak')
|
||||
final int studyStreak;
|
||||
|
||||
@JsonKey(name: 'total_study_days')
|
||||
final int totalStudyDays;
|
||||
|
||||
@JsonKey(name: 'vocabulary_count')
|
||||
final int vocabularyCount;
|
||||
|
||||
@JsonKey(name: 'experience_points')
|
||||
final int experiencePoints;
|
||||
|
||||
@JsonKey(name: 'current_level')
|
||||
final int currentLevel;
|
||||
|
||||
@JsonKey(name: 'created_at')
|
||||
final DateTime createdAt;
|
||||
|
||||
@JsonKey(name: 'updated_at')
|
||||
final DateTime updatedAt;
|
||||
|
||||
@JsonKey(name: 'last_login_at')
|
||||
final DateTime? lastLoginAt;
|
||||
|
||||
@JsonKey(name: 'is_premium')
|
||||
final bool isPremium;
|
||||
|
||||
@JsonKey(name: 'premium_expires_at')
|
||||
final DateTime? premiumExpiresAt;
|
||||
|
||||
const UserModel({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.email,
|
||||
this.nickname,
|
||||
this.avatar,
|
||||
this.phone,
|
||||
this.birthday,
|
||||
this.gender,
|
||||
this.bio,
|
||||
required this.learningLevel,
|
||||
required this.targetLanguage,
|
||||
required this.nativeLanguage,
|
||||
required this.dailyGoal,
|
||||
required this.studyStreak,
|
||||
required this.totalStudyDays,
|
||||
required this.vocabularyCount,
|
||||
required this.experiencePoints,
|
||||
required this.currentLevel,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.lastLoginAt,
|
||||
required this.isPremium,
|
||||
this.premiumExpiresAt,
|
||||
});
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) => _$UserModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$UserModelToJson(this);
|
||||
|
||||
UserModel copyWith({
|
||||
int? userId,
|
||||
String? username,
|
||||
String? email,
|
||||
String? nickname,
|
||||
String? avatar,
|
||||
String? phone,
|
||||
DateTime? birthday,
|
||||
String? gender,
|
||||
String? bio,
|
||||
String? learningLevel,
|
||||
String? targetLanguage,
|
||||
String? nativeLanguage,
|
||||
int? dailyGoal,
|
||||
int? studyStreak,
|
||||
int? totalStudyDays,
|
||||
int? vocabularyCount,
|
||||
int? experiencePoints,
|
||||
int? currentLevel,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? lastLoginAt,
|
||||
bool? isPremium,
|
||||
DateTime? premiumExpiresAt,
|
||||
}) {
|
||||
return UserModel(
|
||||
userId: userId ?? this.userId,
|
||||
username: username ?? this.username,
|
||||
email: email ?? this.email,
|
||||
nickname: nickname ?? this.nickname,
|
||||
avatar: avatar ?? this.avatar,
|
||||
phone: phone ?? this.phone,
|
||||
birthday: birthday ?? this.birthday,
|
||||
gender: gender ?? this.gender,
|
||||
bio: bio ?? this.bio,
|
||||
learningLevel: learningLevel ?? this.learningLevel,
|
||||
targetLanguage: targetLanguage ?? this.targetLanguage,
|
||||
nativeLanguage: nativeLanguage ?? this.nativeLanguage,
|
||||
dailyGoal: dailyGoal ?? this.dailyGoal,
|
||||
studyStreak: studyStreak ?? this.studyStreak,
|
||||
totalStudyDays: totalStudyDays ?? this.totalStudyDays,
|
||||
vocabularyCount: vocabularyCount ?? this.vocabularyCount,
|
||||
experiencePoints: experiencePoints ?? this.experiencePoints,
|
||||
currentLevel: currentLevel ?? this.currentLevel,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
|
||||
isPremium: isPremium ?? this.isPremium,
|
||||
premiumExpiresAt: premiumExpiresAt ?? this.premiumExpiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is UserModel &&
|
||||
other.userId == userId &&
|
||||
other.username == username &&
|
||||
other.email == email;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return userId.hashCode ^
|
||||
username.hashCode ^
|
||||
email.hashCode;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UserModel(userId: $userId, username: $username, email: $email)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户统计信息模型
|
||||
@JsonSerializable()
|
||||
class UserStatsModel {
|
||||
@JsonKey(name: 'total_words_learned')
|
||||
final int totalWordsLearned;
|
||||
|
||||
@JsonKey(name: 'words_learned_today')
|
||||
final int wordsLearnedToday;
|
||||
|
||||
@JsonKey(name: 'study_time_today')
|
||||
final int studyTimeToday; // 分钟
|
||||
|
||||
@JsonKey(name: 'total_study_time')
|
||||
final int totalStudyTime; // 分钟
|
||||
|
||||
@JsonKey(name: 'listening_score')
|
||||
final double listeningScore;
|
||||
|
||||
@JsonKey(name: 'reading_score')
|
||||
final double readingScore;
|
||||
|
||||
@JsonKey(name: 'writing_score')
|
||||
final double writingScore;
|
||||
|
||||
@JsonKey(name: 'speaking_score')
|
||||
final double speakingScore;
|
||||
|
||||
@JsonKey(name: 'overall_score')
|
||||
final double overallScore;
|
||||
|
||||
@JsonKey(name: 'weekly_progress')
|
||||
final List<int> weeklyProgress;
|
||||
|
||||
@JsonKey(name: 'monthly_progress')
|
||||
final List<int> monthlyProgress;
|
||||
|
||||
const UserStatsModel({
|
||||
required this.totalWordsLearned,
|
||||
required this.wordsLearnedToday,
|
||||
required this.studyTimeToday,
|
||||
required this.totalStudyTime,
|
||||
required this.listeningScore,
|
||||
required this.readingScore,
|
||||
required this.writingScore,
|
||||
required this.speakingScore,
|
||||
required this.overallScore,
|
||||
required this.weeklyProgress,
|
||||
required this.monthlyProgress,
|
||||
});
|
||||
|
||||
factory UserStatsModel.fromJson(Map<String, dynamic> json) => _$UserStatsModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$UserStatsModelToJson(this);
|
||||
}
|
||||
99
client/lib/shared/models/user_model.g.dart
Normal file
99
client/lib/shared/models/user_model.g.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
UserModel _$UserModelFromJson(Map<String, dynamic> json) => UserModel(
|
||||
userId: (json['user_id'] as num).toInt(),
|
||||
username: json['username'] as String,
|
||||
email: json['email'] as String,
|
||||
nickname: json['nickname'] as String?,
|
||||
avatar: json['avatar'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
birthday: json['birthday'] == null
|
||||
? null
|
||||
: DateTime.parse(json['birthday'] as String),
|
||||
gender: json['gender'] as String?,
|
||||
bio: json['bio'] as String?,
|
||||
learningLevel: json['learning_level'] as String,
|
||||
targetLanguage: json['target_language'] as String,
|
||||
nativeLanguage: json['native_language'] as String,
|
||||
dailyGoal: (json['daily_goal'] as num).toInt(),
|
||||
studyStreak: (json['study_streak'] as num).toInt(),
|
||||
totalStudyDays: (json['total_study_days'] as num).toInt(),
|
||||
vocabularyCount: (json['vocabulary_count'] as num).toInt(),
|
||||
experiencePoints: (json['experience_points'] as num).toInt(),
|
||||
currentLevel: (json['current_level'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
lastLoginAt: json['last_login_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_login_at'] as String),
|
||||
isPremium: json['is_premium'] as bool,
|
||||
premiumExpiresAt: json['premium_expires_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['premium_expires_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UserModelToJson(UserModel instance) => <String, dynamic>{
|
||||
'user_id': instance.userId,
|
||||
'username': instance.username,
|
||||
'email': instance.email,
|
||||
'nickname': instance.nickname,
|
||||
'avatar': instance.avatar,
|
||||
'phone': instance.phone,
|
||||
'birthday': instance.birthday?.toIso8601String(),
|
||||
'gender': instance.gender,
|
||||
'bio': instance.bio,
|
||||
'learning_level': instance.learningLevel,
|
||||
'target_language': instance.targetLanguage,
|
||||
'native_language': instance.nativeLanguage,
|
||||
'daily_goal': instance.dailyGoal,
|
||||
'study_streak': instance.studyStreak,
|
||||
'total_study_days': instance.totalStudyDays,
|
||||
'vocabulary_count': instance.vocabularyCount,
|
||||
'experience_points': instance.experiencePoints,
|
||||
'current_level': instance.currentLevel,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'last_login_at': instance.lastLoginAt?.toIso8601String(),
|
||||
'is_premium': instance.isPremium,
|
||||
'premium_expires_at': instance.premiumExpiresAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
UserStatsModel _$UserStatsModelFromJson(Map<String, dynamic> json) =>
|
||||
UserStatsModel(
|
||||
totalWordsLearned: (json['total_words_learned'] as num).toInt(),
|
||||
wordsLearnedToday: (json['words_learned_today'] as num).toInt(),
|
||||
studyTimeToday: (json['study_time_today'] as num).toInt(),
|
||||
totalStudyTime: (json['total_study_time'] as num).toInt(),
|
||||
listeningScore: (json['listening_score'] as num).toDouble(),
|
||||
readingScore: (json['reading_score'] as num).toDouble(),
|
||||
writingScore: (json['writing_score'] as num).toDouble(),
|
||||
speakingScore: (json['speaking_score'] as num).toDouble(),
|
||||
overallScore: (json['overall_score'] as num).toDouble(),
|
||||
weeklyProgress: (json['weekly_progress'] as List<dynamic>)
|
||||
.map((e) => (e as num).toInt())
|
||||
.toList(),
|
||||
monthlyProgress: (json['monthly_progress'] as List<dynamic>)
|
||||
.map((e) => (e as num).toInt())
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UserStatsModelToJson(UserStatsModel instance) =>
|
||||
<String, dynamic>{
|
||||
'total_words_learned': instance.totalWordsLearned,
|
||||
'words_learned_today': instance.wordsLearnedToday,
|
||||
'study_time_today': instance.studyTimeToday,
|
||||
'total_study_time': instance.totalStudyTime,
|
||||
'listening_score': instance.listeningScore,
|
||||
'reading_score': instance.readingScore,
|
||||
'writing_score': instance.writingScore,
|
||||
'speaking_score': instance.speakingScore,
|
||||
'overall_score': instance.overallScore,
|
||||
'weekly_progress': instance.weeklyProgress,
|
||||
'monthly_progress': instance.monthlyProgress,
|
||||
};
|
||||
292
client/lib/shared/models/vocabulary_model.dart
Normal file
292
client/lib/shared/models/vocabulary_model.dart
Normal file
@@ -0,0 +1,292 @@
|
||||
/// 词汇模型
|
||||
class VocabularyModel {
|
||||
final int wordId;
|
||||
final String word;
|
||||
final String pronunciation;
|
||||
final String phonetic;
|
||||
final List<WordMeaning> meanings;
|
||||
final String? etymology;
|
||||
final List<String> examples;
|
||||
final String? imageUrl;
|
||||
final String? audioUrl;
|
||||
final int difficulty;
|
||||
final List<String> tags;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const VocabularyModel({
|
||||
required this.wordId,
|
||||
required this.word,
|
||||
required this.pronunciation,
|
||||
required this.phonetic,
|
||||
required this.meanings,
|
||||
this.etymology,
|
||||
required this.examples,
|
||||
this.imageUrl,
|
||||
this.audioUrl,
|
||||
required this.difficulty,
|
||||
required this.tags,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory VocabularyModel.fromJson(Map<String, dynamic> json) {
|
||||
return VocabularyModel(
|
||||
wordId: json['word_id'] as int,
|
||||
word: json['word'] as String,
|
||||
pronunciation: json['pronunciation'] as String,
|
||||
phonetic: json['phonetic'] as String,
|
||||
meanings: (json['meanings'] as List)
|
||||
.map((e) => WordMeaning.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
etymology: json['etymology'] as String?,
|
||||
examples: (json['examples'] as List).cast<String>(),
|
||||
imageUrl: json['image_url'] as String?,
|
||||
audioUrl: json['audio_url'] as String?,
|
||||
difficulty: json['difficulty'] as int,
|
||||
tags: (json['tags'] as List).cast<String>(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'word_id': wordId,
|
||||
'word': word,
|
||||
'pronunciation': pronunciation,
|
||||
'phonetic': phonetic,
|
||||
'meanings': meanings.map((e) => e.toJson()).toList(),
|
||||
'etymology': etymology,
|
||||
'examples': examples,
|
||||
'image_url': imageUrl,
|
||||
'audio_url': audioUrl,
|
||||
'difficulty': difficulty,
|
||||
'tags': tags,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
VocabularyModel copyWith({
|
||||
int? wordId,
|
||||
String? word,
|
||||
String? pronunciation,
|
||||
String? phonetic,
|
||||
List<WordMeaning>? meanings,
|
||||
String? etymology,
|
||||
List<String>? examples,
|
||||
String? imageUrl,
|
||||
String? audioUrl,
|
||||
int? difficulty,
|
||||
List<String>? tags,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return VocabularyModel(
|
||||
wordId: wordId ?? this.wordId,
|
||||
word: word ?? this.word,
|
||||
pronunciation: pronunciation ?? this.pronunciation,
|
||||
phonetic: phonetic ?? this.phonetic,
|
||||
meanings: meanings ?? this.meanings,
|
||||
etymology: etymology ?? this.etymology,
|
||||
examples: examples ?? this.examples,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
audioUrl: audioUrl ?? this.audioUrl,
|
||||
difficulty: difficulty ?? this.difficulty,
|
||||
tags: tags ?? this.tags,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 词汇含义模型
|
||||
class WordMeaning {
|
||||
final String partOfSpeech;
|
||||
final String definition;
|
||||
final String? chineseDefinition;
|
||||
final List<String> synonyms;
|
||||
final List<String> antonyms;
|
||||
final List<String> examples;
|
||||
|
||||
const WordMeaning({
|
||||
required this.partOfSpeech,
|
||||
required this.definition,
|
||||
this.chineseDefinition,
|
||||
required this.synonyms,
|
||||
required this.antonyms,
|
||||
required this.examples,
|
||||
});
|
||||
|
||||
factory WordMeaning.fromJson(Map<String, dynamic> json) {
|
||||
return WordMeaning(
|
||||
partOfSpeech: json['part_of_speech'] as String,
|
||||
definition: json['definition'] as String,
|
||||
chineseDefinition: json['chinese_definition'] as String?,
|
||||
synonyms: (json['synonyms'] as List).cast<String>(),
|
||||
antonyms: (json['antonyms'] as List).cast<String>(),
|
||||
examples: (json['examples'] as List).cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'part_of_speech': partOfSpeech,
|
||||
'definition': definition,
|
||||
'chinese_definition': chineseDefinition,
|
||||
'synonyms': synonyms,
|
||||
'antonyms': antonyms,
|
||||
'examples': examples,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户词汇学习记录模型
|
||||
class UserVocabularyModel {
|
||||
final int userWordId;
|
||||
final int userId;
|
||||
final int wordId;
|
||||
final VocabularyModel? vocabulary;
|
||||
final LearningStatus status;
|
||||
final int reviewCount;
|
||||
final int correctCount;
|
||||
final int incorrectCount;
|
||||
final double masteryLevel;
|
||||
final DateTime? lastReviewAt;
|
||||
final DateTime? nextReviewAt;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const UserVocabularyModel({
|
||||
required this.userWordId,
|
||||
required this.userId,
|
||||
required this.wordId,
|
||||
this.vocabulary,
|
||||
required this.status,
|
||||
required this.reviewCount,
|
||||
required this.correctCount,
|
||||
required this.incorrectCount,
|
||||
required this.masteryLevel,
|
||||
this.lastReviewAt,
|
||||
this.nextReviewAt,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory UserVocabularyModel.fromJson(Map<String, dynamic> json) {
|
||||
return UserVocabularyModel(
|
||||
userWordId: json['user_word_id'] as int,
|
||||
userId: json['user_id'] as int,
|
||||
wordId: json['word_id'] as int,
|
||||
vocabulary: json['vocabulary'] != null
|
||||
? VocabularyModel.fromJson(json['vocabulary'] as Map<String, dynamic>)
|
||||
: null,
|
||||
status: LearningStatus.values.firstWhere(
|
||||
(e) => e.name == json['status'],
|
||||
orElse: () => LearningStatus.new_word,
|
||||
),
|
||||
reviewCount: json['review_count'] as int,
|
||||
correctCount: json['correct_count'] as int,
|
||||
incorrectCount: json['incorrect_count'] as int,
|
||||
masteryLevel: (json['mastery_level'] as num).toDouble(),
|
||||
lastReviewAt: json['last_review_at'] != null
|
||||
? DateTime.parse(json['last_review_at'] as String)
|
||||
: null,
|
||||
nextReviewAt: json['next_review_at'] != null
|
||||
? DateTime.parse(json['next_review_at'] as String)
|
||||
: null,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'user_word_id': userWordId,
|
||||
'user_id': userId,
|
||||
'word_id': wordId,
|
||||
'vocabulary': vocabulary?.toJson(),
|
||||
'status': status.name,
|
||||
'review_count': reviewCount,
|
||||
'correct_count': correctCount,
|
||||
'incorrect_count': incorrectCount,
|
||||
'mastery_level': masteryLevel,
|
||||
'last_review_at': lastReviewAt?.toIso8601String(),
|
||||
'next_review_at': nextReviewAt?.toIso8601String(),
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 学习状态枚举
|
||||
enum LearningStatus {
|
||||
new_word('new_word', '新单词'),
|
||||
learning('learning', '学习中'),
|
||||
reviewing('reviewing', '复习中'),
|
||||
mastered('mastered', '已掌握'),
|
||||
forgotten('forgotten', '已遗忘');
|
||||
|
||||
const LearningStatus(this.value, this.label);
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 词库模型
|
||||
class VocabularyBookModel {
|
||||
final int bookId;
|
||||
final String name;
|
||||
final String description;
|
||||
final String category;
|
||||
final String level;
|
||||
final int totalWords;
|
||||
final String? coverImage;
|
||||
final bool isPremium;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const VocabularyBookModel({
|
||||
required this.bookId,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.category,
|
||||
required this.level,
|
||||
required this.totalWords,
|
||||
this.coverImage,
|
||||
required this.isPremium,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory VocabularyBookModel.fromJson(Map<String, dynamic> json) {
|
||||
return VocabularyBookModel(
|
||||
bookId: json['book_id'] as int,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
category: json['category'] as String,
|
||||
level: json['level'] as String,
|
||||
totalWords: json['total_words'] as int,
|
||||
coverImage: json['cover_image'] as String?,
|
||||
isPremium: json['is_premium'] as bool,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'book_id': bookId,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'category': category,
|
||||
'level': level,
|
||||
'total_words': totalWords,
|
||||
'cover_image': coverImage,
|
||||
'is_premium': isPremium,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
289
client/lib/shared/providers/auth_provider.dart
Normal file
289
client/lib/shared/providers/auth_provider.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/user_model.dart';
|
||||
import '../models/api_response.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../../core/storage/storage_service.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
|
||||
/// 认证状态
|
||||
enum AuthState {
|
||||
initial,
|
||||
loading,
|
||||
authenticated,
|
||||
unauthenticated,
|
||||
error,
|
||||
}
|
||||
|
||||
/// 认证Provider
|
||||
class AuthProvider extends ChangeNotifier {
|
||||
final AuthService _authService = AuthService();
|
||||
|
||||
AuthState _state = AuthState.initial;
|
||||
UserModel? _user;
|
||||
String? _errorMessage;
|
||||
bool _isLoading = false;
|
||||
|
||||
// Getters
|
||||
AuthState get state => _state;
|
||||
UserModel? get user => _user;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isAuthenticated => _state == AuthState.authenticated && _user != null;
|
||||
|
||||
/// 初始化认证状态
|
||||
Future<void> initialize() async {
|
||||
_setLoading(true);
|
||||
|
||||
try {
|
||||
if (_authService.isLoggedIn()) {
|
||||
final cachedUser = _authService.getCachedUser();
|
||||
if (cachedUser != null) {
|
||||
_user = cachedUser;
|
||||
_setState(AuthState.authenticated);
|
||||
|
||||
// 尝试刷新用户信息
|
||||
await _refreshUserInfo();
|
||||
} else {
|
||||
_setState(AuthState.unauthenticated);
|
||||
}
|
||||
} else {
|
||||
_setState(AuthState.unauthenticated);
|
||||
}
|
||||
} catch (e) {
|
||||
_setError('初始化失败: $e');
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户注册
|
||||
Future<bool> register({
|
||||
required String username,
|
||||
required String email,
|
||||
required String password,
|
||||
required String nickname,
|
||||
String? phone,
|
||||
}) async {
|
||||
_setLoading(true);
|
||||
_clearError();
|
||||
|
||||
try {
|
||||
final response = await _authService.register(
|
||||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
nickname: nickname,
|
||||
phone: phone,
|
||||
);
|
||||
|
||||
if (response.success && response.data != null && response.data!.user != null) {
|
||||
_user = UserModel.fromJson(response.data!.user!);
|
||||
_setState(AuthState.authenticated);
|
||||
return true;
|
||||
} else {
|
||||
_setError(response.message);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_setError('注册失败: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户登录
|
||||
Future<bool> login({
|
||||
required String account, // 用户名或邮箱
|
||||
required String password,
|
||||
bool rememberMe = false,
|
||||
}) async {
|
||||
_setLoading(true);
|
||||
_clearError();
|
||||
|
||||
try {
|
||||
final response = await _authService.login(
|
||||
account: account,
|
||||
password: password,
|
||||
rememberMe: rememberMe,
|
||||
);
|
||||
|
||||
if (response.success && response.data != null && response.data!.user != null) {
|
||||
_user = UserModel.fromJson(response.data!.user!);
|
||||
_setState(AuthState.authenticated);
|
||||
return true;
|
||||
} else {
|
||||
_setError(response.message);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_setError('登录失败: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户登出
|
||||
Future<void> logout() async {
|
||||
_setLoading(true);
|
||||
|
||||
try {
|
||||
await _authService.logout();
|
||||
} catch (e) {
|
||||
// 即使登出请求失败,也要清除本地状态
|
||||
debugPrint('Logout error: $e');
|
||||
} finally {
|
||||
_user = null;
|
||||
_setState(AuthState.unauthenticated);
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新用户信息
|
||||
Future<void> _refreshUserInfo() async {
|
||||
try {
|
||||
final response = await _authService.getCurrentUser();
|
||||
if (response.success && response.data != null) {
|
||||
_user = response.data;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Refresh user info error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新用户信息
|
||||
Future<bool> updateProfile({
|
||||
String? nickname,
|
||||
String? avatar,
|
||||
String? phone,
|
||||
DateTime? birthday,
|
||||
String? gender,
|
||||
String? bio,
|
||||
String? learningLevel,
|
||||
String? targetLanguage,
|
||||
String? nativeLanguage,
|
||||
int? dailyGoal,
|
||||
}) async {
|
||||
_setLoading(true);
|
||||
_clearError();
|
||||
|
||||
try {
|
||||
final response = await _authService.updateProfile(
|
||||
nickname: nickname,
|
||||
avatar: avatar,
|
||||
phone: phone,
|
||||
birthday: birthday,
|
||||
gender: gender,
|
||||
bio: bio,
|
||||
learningLevel: learningLevel,
|
||||
targetLanguage: targetLanguage,
|
||||
nativeLanguage: nativeLanguage,
|
||||
dailyGoal: dailyGoal,
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
_user = response.data;
|
||||
notifyListeners();
|
||||
return true;
|
||||
} else {
|
||||
_setError(response.message);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_setError('更新个人信息失败: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 修改密码
|
||||
Future<bool> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
required String confirmPassword,
|
||||
}) async {
|
||||
_setLoading(true);
|
||||
_clearError();
|
||||
|
||||
try {
|
||||
final response = await _authService.changePassword(
|
||||
currentPassword: currentPassword,
|
||||
newPassword: newPassword,
|
||||
confirmPassword: confirmPassword,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
return true;
|
||||
} else {
|
||||
_setError(response.message);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_setError('修改密码失败: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Token
|
||||
Future<bool> refreshToken() async {
|
||||
try {
|
||||
final response = await _authService.refreshToken();
|
||||
|
||||
if (response.success && response.data != null && response.data!.user != null) {
|
||||
_user = UserModel.fromJson(response.data!.user!);
|
||||
if (_state != AuthState.authenticated) {
|
||||
_setState(AuthState.authenticated);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
// Token刷新失败,需要重新登录
|
||||
_user = null;
|
||||
_setState(AuthState.unauthenticated);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_user = null;
|
||||
_setState(AuthState.unauthenticated);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置加载状态
|
||||
void _setLoading(bool loading) {
|
||||
_isLoading = loading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 设置状态
|
||||
void _setState(AuthState state) {
|
||||
_state = state;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 设置错误信息
|
||||
void _setError(String message) {
|
||||
_errorMessage = message;
|
||||
_setState(AuthState.error);
|
||||
}
|
||||
|
||||
/// 清除错误信息
|
||||
void _clearError() {
|
||||
_errorMessage = null;
|
||||
if (_state == AuthState.error) {
|
||||
_setState(_user != null ? AuthState.authenticated : AuthState.unauthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除所有状态
|
||||
void clear() {
|
||||
_user = null;
|
||||
_errorMessage = null;
|
||||
_isLoading = false;
|
||||
_setState(AuthState.initial);
|
||||
}
|
||||
}
|
||||
382
client/lib/shared/providers/error_provider.dart
Normal file
382
client/lib/shared/providers/error_provider.dart
Normal file
@@ -0,0 +1,382 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 错误类型枚举
|
||||
enum ErrorType {
|
||||
network,
|
||||
authentication,
|
||||
validation,
|
||||
server,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// 错误严重程度枚举
|
||||
enum ErrorSeverity {
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
critical,
|
||||
}
|
||||
|
||||
/// 应用错误模型
|
||||
class AppError {
|
||||
final String id;
|
||||
final String message;
|
||||
final String? details;
|
||||
final ErrorType type;
|
||||
final ErrorSeverity severity;
|
||||
final DateTime timestamp;
|
||||
final String? stackTrace;
|
||||
final Map<String, dynamic>? context;
|
||||
|
||||
const AppError({
|
||||
required this.id,
|
||||
required this.message,
|
||||
this.details,
|
||||
required this.type,
|
||||
required this.severity,
|
||||
required this.timestamp,
|
||||
this.stackTrace,
|
||||
this.context,
|
||||
});
|
||||
|
||||
AppError copyWith({
|
||||
String? id,
|
||||
String? message,
|
||||
String? details,
|
||||
ErrorType? type,
|
||||
ErrorSeverity? severity,
|
||||
DateTime? timestamp,
|
||||
String? stackTrace,
|
||||
Map<String, dynamic>? context,
|
||||
}) {
|
||||
return AppError(
|
||||
id: id ?? this.id,
|
||||
message: message ?? this.message,
|
||||
details: details ?? this.details,
|
||||
type: type ?? this.type,
|
||||
severity: severity ?? this.severity,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
stackTrace: stackTrace ?? this.stackTrace,
|
||||
context: context ?? this.context,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is AppError &&
|
||||
other.id == id &&
|
||||
other.message == message &&
|
||||
other.type == type &&
|
||||
other.severity == severity;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
message.hashCode ^
|
||||
type.hashCode ^
|
||||
severity.hashCode;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AppError(id: $id, message: $message, type: $type, severity: $severity, timestamp: $timestamp)';
|
||||
}
|
||||
|
||||
/// 创建网络错误
|
||||
factory AppError.network(String message, {String? details, Map<String, dynamic>? context}) {
|
||||
return AppError(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
message: message,
|
||||
details: details,
|
||||
type: ErrorType.network,
|
||||
severity: ErrorSeverity.error,
|
||||
timestamp: DateTime.now(),
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建认证错误
|
||||
factory AppError.authentication(String message, {String? details, Map<String, dynamic>? context}) {
|
||||
return AppError(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
message: message,
|
||||
details: details,
|
||||
type: ErrorType.authentication,
|
||||
severity: ErrorSeverity.error,
|
||||
timestamp: DateTime.now(),
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建验证错误
|
||||
factory AppError.validation(String message, {String? details, Map<String, dynamic>? context}) {
|
||||
return AppError(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
message: message,
|
||||
details: details,
|
||||
type: ErrorType.validation,
|
||||
severity: ErrorSeverity.warning,
|
||||
timestamp: DateTime.now(),
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建服务器错误
|
||||
factory AppError.server(String message, {String? details, Map<String, dynamic>? context}) {
|
||||
return AppError(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
message: message,
|
||||
details: details,
|
||||
type: ErrorType.server,
|
||||
severity: ErrorSeverity.error,
|
||||
timestamp: DateTime.now(),
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建未知错误
|
||||
factory AppError.unknown(String message, {String? details, String? stackTrace, Map<String, dynamic>? context}) {
|
||||
return AppError(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
message: message,
|
||||
details: details,
|
||||
type: ErrorType.unknown,
|
||||
severity: ErrorSeverity.error,
|
||||
timestamp: DateTime.now(),
|
||||
stackTrace: stackTrace,
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误状态模型
|
||||
class ErrorState {
|
||||
final List<AppError> errors;
|
||||
final AppError? currentError;
|
||||
|
||||
const ErrorState({
|
||||
required this.errors,
|
||||
this.currentError,
|
||||
});
|
||||
|
||||
bool get hasErrors => errors.isNotEmpty;
|
||||
|
||||
ErrorState copyWith({
|
||||
List<AppError>? errors,
|
||||
AppError? currentError,
|
||||
}) {
|
||||
return ErrorState(
|
||||
errors: errors ?? this.errors,
|
||||
currentError: currentError,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ErrorState &&
|
||||
other.errors.length == errors.length &&
|
||||
other.currentError == currentError;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return errors.hashCode ^ currentError.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误管理Provider
|
||||
class ErrorNotifier extends StateNotifier<ErrorState> {
|
||||
static const int maxErrorCount = 50; // 最大错误数量
|
||||
static const Duration errorRetentionDuration = Duration(hours: 24); // 错误保留时间
|
||||
|
||||
ErrorNotifier() : super(const ErrorState(errors: []));
|
||||
|
||||
/// 添加错误
|
||||
void addError(AppError error) {
|
||||
final updatedErrors = List<AppError>.from(state.errors);
|
||||
updatedErrors.insert(0, error); // 新错误插入到列表开头
|
||||
|
||||
// 限制错误数量
|
||||
if (updatedErrors.length > maxErrorCount) {
|
||||
updatedErrors.removeRange(maxErrorCount, updatedErrors.length);
|
||||
}
|
||||
|
||||
// 清理过期错误
|
||||
_cleanExpiredErrors(updatedErrors);
|
||||
|
||||
state = state.copyWith(
|
||||
errors: updatedErrors,
|
||||
currentError: error,
|
||||
);
|
||||
|
||||
// 在调试模式下打印错误
|
||||
if (kDebugMode) {
|
||||
print('Error added: ${error.toString()}');
|
||||
if (error.stackTrace != null) {
|
||||
print('Stack trace: ${error.stackTrace}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除当前错误
|
||||
void clearCurrentError() {
|
||||
state = state.copyWith(currentError: null);
|
||||
}
|
||||
|
||||
/// 清除指定错误
|
||||
void removeError(String errorId) {
|
||||
final updatedErrors = state.errors.where((error) => error.id != errorId).toList();
|
||||
|
||||
AppError? newCurrentError = state.currentError;
|
||||
if (state.currentError?.id == errorId) {
|
||||
newCurrentError = null;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
errors: updatedErrors,
|
||||
currentError: newCurrentError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 清除所有错误
|
||||
void clearAllErrors() {
|
||||
state = const ErrorState(errors: []);
|
||||
}
|
||||
|
||||
/// 清除指定类型的错误
|
||||
void clearErrorsByType(ErrorType type) {
|
||||
final updatedErrors = state.errors.where((error) => error.type != type).toList();
|
||||
|
||||
AppError? newCurrentError = state.currentError;
|
||||
if (state.currentError?.type == type) {
|
||||
newCurrentError = null;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
errors: updatedErrors,
|
||||
currentError: newCurrentError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 清除指定严重程度的错误
|
||||
void clearErrorsBySeverity(ErrorSeverity severity) {
|
||||
final updatedErrors = state.errors.where((error) => error.severity != severity).toList();
|
||||
|
||||
AppError? newCurrentError = state.currentError;
|
||||
if (state.currentError?.severity == severity) {
|
||||
newCurrentError = null;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
errors: updatedErrors,
|
||||
currentError: newCurrentError,
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取指定类型的错误
|
||||
List<AppError> getErrorsByType(ErrorType type) {
|
||||
return state.errors.where((error) => error.type == type).toList();
|
||||
}
|
||||
|
||||
/// 获取指定严重程度的错误
|
||||
List<AppError> getErrorsBySeverity(ErrorSeverity severity) {
|
||||
return state.errors.where((error) => error.severity == severity).toList();
|
||||
}
|
||||
|
||||
/// 获取最近的错误
|
||||
List<AppError> getRecentErrors({int count = 10}) {
|
||||
return state.errors.take(count).toList();
|
||||
}
|
||||
|
||||
/// 检查是否有指定类型的错误
|
||||
bool hasErrorOfType(ErrorType type) {
|
||||
return state.errors.any((error) => error.type == type);
|
||||
}
|
||||
|
||||
/// 检查是否有指定严重程度的错误
|
||||
bool hasErrorOfSeverity(ErrorSeverity severity) {
|
||||
return state.errors.any((error) => error.severity == severity);
|
||||
}
|
||||
|
||||
/// 清理过期错误
|
||||
void _cleanExpiredErrors(List<AppError> errors) {
|
||||
final now = DateTime.now();
|
||||
errors.removeWhere((error) =>
|
||||
now.difference(error.timestamp) > errorRetentionDuration);
|
||||
}
|
||||
|
||||
/// 处理异常并转换为AppError
|
||||
void handleException(dynamic exception, {
|
||||
String? message,
|
||||
ErrorType? type,
|
||||
ErrorSeverity? severity,
|
||||
Map<String, dynamic>? context,
|
||||
}) {
|
||||
String errorMessage = message ?? exception.toString();
|
||||
ErrorType errorType = type ?? ErrorType.unknown;
|
||||
ErrorSeverity errorSeverity = severity ?? ErrorSeverity.error;
|
||||
String? stackTrace;
|
||||
|
||||
if (exception is Error) {
|
||||
stackTrace = exception.stackTrace?.toString();
|
||||
}
|
||||
|
||||
final error = AppError(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
message: errorMessage,
|
||||
details: exception.toString(),
|
||||
type: errorType,
|
||||
severity: errorSeverity,
|
||||
timestamp: DateTime.now(),
|
||||
stackTrace: stackTrace,
|
||||
context: context,
|
||||
);
|
||||
|
||||
addError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误状态Provider
|
||||
final errorProvider = StateNotifierProvider<ErrorNotifier, ErrorState>(
|
||||
(ref) => ErrorNotifier(),
|
||||
);
|
||||
|
||||
/// 当前错误Provider
|
||||
final currentErrorProvider = Provider<AppError?>(
|
||||
(ref) => ref.watch(errorProvider).currentError,
|
||||
);
|
||||
|
||||
/// 是否有错误Provider
|
||||
final hasErrorsProvider = Provider<bool>(
|
||||
(ref) => ref.watch(errorProvider).hasErrors,
|
||||
);
|
||||
|
||||
/// 错误数量Provider
|
||||
final errorCountProvider = Provider<int>(
|
||||
(ref) => ref.watch(errorProvider).errors.length,
|
||||
);
|
||||
|
||||
/// 网络错误Provider
|
||||
final networkErrorsProvider = Provider<List<AppError>>(
|
||||
(ref) => ref.watch(errorProvider).errors
|
||||
.where((error) => error.type == ErrorType.network)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
/// 认证错误Provider
|
||||
final authErrorsProvider = Provider<List<AppError>>(
|
||||
(ref) => ref.watch(errorProvider).errors
|
||||
.where((error) => error.type == ErrorType.authentication)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
/// 严重错误Provider
|
||||
final criticalErrorsProvider = Provider<List<AppError>>(
|
||||
(ref) => ref.watch(errorProvider).errors
|
||||
.where((error) => error.severity == ErrorSeverity.critical)
|
||||
.toList(),
|
||||
);
|
||||
239
client/lib/shared/providers/network_provider.dart
Normal file
239
client/lib/shared/providers/network_provider.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// 网络连接状态枚举
|
||||
enum NetworkStatus {
|
||||
connected,
|
||||
disconnected,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// 网络连接类型枚举
|
||||
enum NetworkType {
|
||||
wifi,
|
||||
mobile,
|
||||
ethernet,
|
||||
none,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// 网络状态数据模型
|
||||
class NetworkState {
|
||||
final NetworkStatus status;
|
||||
final NetworkType type;
|
||||
final bool isOnline;
|
||||
final DateTime lastChecked;
|
||||
|
||||
const NetworkState({
|
||||
required this.status,
|
||||
required this.type,
|
||||
required this.isOnline,
|
||||
required this.lastChecked,
|
||||
});
|
||||
|
||||
NetworkState copyWith({
|
||||
NetworkStatus? status,
|
||||
NetworkType? type,
|
||||
bool? isOnline,
|
||||
DateTime? lastChecked,
|
||||
}) {
|
||||
return NetworkState(
|
||||
status: status ?? this.status,
|
||||
type: type ?? this.type,
|
||||
isOnline: isOnline ?? this.isOnline,
|
||||
lastChecked: lastChecked ?? this.lastChecked,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is NetworkState &&
|
||||
other.status == status &&
|
||||
other.type == type &&
|
||||
other.isOnline == isOnline;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return status.hashCode ^
|
||||
type.hashCode ^
|
||||
isOnline.hashCode;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NetworkState(status: $status, type: $type, isOnline: $isOnline, lastChecked: $lastChecked)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络状态管理Provider
|
||||
class NetworkNotifier extends StateNotifier<NetworkState> {
|
||||
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
|
||||
NetworkNotifier()
|
||||
: super(NetworkState(
|
||||
status: NetworkStatus.unknown,
|
||||
type: NetworkType.unknown,
|
||||
isOnline: false,
|
||||
lastChecked: DateTime.now(),
|
||||
)) {
|
||||
_initializeNetworkMonitoring();
|
||||
}
|
||||
|
||||
/// 初始化网络监控
|
||||
void _initializeNetworkMonitoring() {
|
||||
// 监听网络连接状态变化
|
||||
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
|
||||
(List<ConnectivityResult> results) {
|
||||
_updateNetworkState(results);
|
||||
},
|
||||
);
|
||||
|
||||
// 初始检查网络状态
|
||||
_checkInitialConnectivity();
|
||||
}
|
||||
|
||||
/// 检查初始网络连接状态
|
||||
Future<void> _checkInitialConnectivity() async {
|
||||
try {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
_updateNetworkState(results);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error checking initial connectivity: $e');
|
||||
}
|
||||
state = state.copyWith(
|
||||
status: NetworkStatus.unknown,
|
||||
type: NetworkType.unknown,
|
||||
isOnline: false,
|
||||
lastChecked: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新网络状态
|
||||
void _updateNetworkState(List<ConnectivityResult> results) {
|
||||
// 取第一个有效的连接结果,如果列表为空则认为无连接
|
||||
final result = results.isNotEmpty ? results.first : ConnectivityResult.none;
|
||||
final networkType = _mapConnectivityResultToNetworkType(result);
|
||||
final isOnline = result != ConnectivityResult.none;
|
||||
final status = isOnline ? NetworkStatus.connected : NetworkStatus.disconnected;
|
||||
|
||||
state = NetworkState(
|
||||
status: status,
|
||||
type: networkType,
|
||||
isOnline: isOnline,
|
||||
lastChecked: DateTime.now(),
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Network state updated: $state');
|
||||
}
|
||||
}
|
||||
|
||||
/// 将ConnectivityResult映射到NetworkType
|
||||
NetworkType _mapConnectivityResultToNetworkType(ConnectivityResult result) {
|
||||
switch (result) {
|
||||
case ConnectivityResult.wifi:
|
||||
return NetworkType.wifi;
|
||||
case ConnectivityResult.mobile:
|
||||
return NetworkType.mobile;
|
||||
case ConnectivityResult.ethernet:
|
||||
return NetworkType.ethernet;
|
||||
case ConnectivityResult.none:
|
||||
return NetworkType.none;
|
||||
default:
|
||||
return NetworkType.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动刷新网络状态
|
||||
Future<void> refreshNetworkStatus() async {
|
||||
try {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
_updateNetworkState(results);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error refreshing network status: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否有网络连接
|
||||
bool get isConnected => state.isOnline;
|
||||
|
||||
/// 检查是否为WiFi连接
|
||||
bool get isWifiConnected => state.type == NetworkType.wifi && state.isOnline;
|
||||
|
||||
/// 检查是否为移动网络连接
|
||||
bool get isMobileConnected => state.type == NetworkType.mobile && state.isOnline;
|
||||
|
||||
/// 获取网络类型描述
|
||||
String get networkTypeDescription {
|
||||
switch (state.type) {
|
||||
case NetworkType.wifi:
|
||||
return 'WiFi';
|
||||
case NetworkType.mobile:
|
||||
return '移动网络';
|
||||
case NetworkType.ethernet:
|
||||
return '以太网';
|
||||
case NetworkType.none:
|
||||
return '无网络';
|
||||
case NetworkType.unknown:
|
||||
return '未知网络';
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取网络状态描述
|
||||
String get statusDescription {
|
||||
switch (state.status) {
|
||||
case NetworkStatus.connected:
|
||||
return '已连接';
|
||||
case NetworkStatus.disconnected:
|
||||
return '已断开';
|
||||
case NetworkStatus.unknown:
|
||||
return '未知状态';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectivitySubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络状态Provider
|
||||
final networkProvider = StateNotifierProvider<NetworkNotifier, NetworkState>(
|
||||
(ref) => NetworkNotifier(),
|
||||
);
|
||||
|
||||
/// 网络连接状态Provider(简化版)
|
||||
final isConnectedProvider = Provider<bool>(
|
||||
(ref) => ref.watch(networkProvider).isOnline,
|
||||
);
|
||||
|
||||
/// 网络类型Provider
|
||||
final networkTypeProvider = Provider<NetworkType>(
|
||||
(ref) => ref.watch(networkProvider).type,
|
||||
);
|
||||
|
||||
/// WiFi连接状态Provider
|
||||
final isWifiConnectedProvider = Provider<bool>(
|
||||
(ref) {
|
||||
final networkState = ref.watch(networkProvider);
|
||||
return networkState.type == NetworkType.wifi && networkState.isOnline;
|
||||
},
|
||||
);
|
||||
|
||||
/// 移动网络连接状态Provider
|
||||
final isMobileConnectedProvider = Provider<bool>(
|
||||
(ref) {
|
||||
final networkState = ref.watch(networkProvider);
|
||||
return networkState.type == NetworkType.mobile && networkState.isOnline;
|
||||
},
|
||||
);
|
||||
222
client/lib/shared/providers/notification_provider.dart
Normal file
222
client/lib/shared/providers/notification_provider.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/notification_model.dart';
|
||||
import '../services/notification_service.dart';
|
||||
|
||||
/// 通知状态
|
||||
class NotificationState {
|
||||
final List<NotificationModel> notifications;
|
||||
final int total;
|
||||
final int unreadCount;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
NotificationState({
|
||||
this.notifications = const [],
|
||||
this.total = 0,
|
||||
this.unreadCount = 0,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
NotificationState copyWith({
|
||||
List<NotificationModel>? notifications,
|
||||
int? total,
|
||||
int? unreadCount,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return NotificationState(
|
||||
notifications: notifications ?? this.notifications,
|
||||
total: total ?? this.total,
|
||||
unreadCount: unreadCount ?? this.unreadCount,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 通知Notifier
|
||||
class NotificationNotifier extends StateNotifier<NotificationState> {
|
||||
final NotificationService _service = NotificationService();
|
||||
|
||||
NotificationNotifier() : super(NotificationState());
|
||||
|
||||
/// 加载通知列表
|
||||
Future<void> loadNotifications({
|
||||
int page = 1,
|
||||
int limit = 10,
|
||||
bool onlyUnread = false,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final response = await _service.getNotifications(
|
||||
page: page,
|
||||
limit: limit,
|
||||
onlyUnread: onlyUnread,
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
state = state.copyWith(
|
||||
notifications: response.data!['notifications'] as List<NotificationModel>,
|
||||
total: response.data!['total'] as int,
|
||||
isLoading: false,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: response.message,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: '加载通知列表失败: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载未读通知数量
|
||||
Future<void> loadUnreadCount() async {
|
||||
try {
|
||||
final response = await _service.getUnreadCount();
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
state = state.copyWith(
|
||||
unreadCount: response.data!,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('加载未读通知数量失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记通知为已读
|
||||
Future<bool> markAsRead(int notificationId) async {
|
||||
try {
|
||||
final response = await _service.markAsRead(notificationId);
|
||||
|
||||
if (response.success) {
|
||||
// 更新本地状态
|
||||
final updatedNotifications = state.notifications.map((n) {
|
||||
if (n.id == notificationId) {
|
||||
return NotificationModel(
|
||||
id: n.id,
|
||||
userId: n.userId,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
link: n.link,
|
||||
isRead: true,
|
||||
priority: n.priority,
|
||||
createdAt: n.createdAt,
|
||||
readAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
return n;
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(
|
||||
notifications: updatedNotifications,
|
||||
unreadCount: state.unreadCount > 0 ? state.unreadCount - 1 : 0,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
print('标记通知已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记所有通知为已读
|
||||
Future<bool> markAllAsRead() async {
|
||||
try {
|
||||
final response = await _service.markAllAsRead();
|
||||
|
||||
if (response.success) {
|
||||
// 更新本地状态
|
||||
final updatedNotifications = state.notifications.map((n) {
|
||||
return NotificationModel(
|
||||
id: n.id,
|
||||
userId: n.userId,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
link: n.link,
|
||||
isRead: true,
|
||||
priority: n.priority,
|
||||
createdAt: n.createdAt,
|
||||
readAt: DateTime.now(),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(
|
||||
notifications: updatedNotifications,
|
||||
unreadCount: 0,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
print('标记所有通知已读失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除通知
|
||||
Future<bool> deleteNotification(int notificationId) async {
|
||||
try {
|
||||
final response = await _service.deleteNotification(notificationId);
|
||||
|
||||
if (response.success) {
|
||||
// 从本地状态中移除
|
||||
final updatedNotifications =
|
||||
state.notifications.where((n) => n.id != notificationId).toList();
|
||||
|
||||
// 如果删除的是未读通知,更新未读数量
|
||||
final deletedNotification =
|
||||
state.notifications.firstWhere((n) => n.id == notificationId);
|
||||
final newUnreadCount = deletedNotification.isRead
|
||||
? state.unreadCount
|
||||
: (state.unreadCount > 0 ? state.unreadCount - 1 : 0);
|
||||
|
||||
state = state.copyWith(
|
||||
notifications: updatedNotifications,
|
||||
total: state.total - 1,
|
||||
unreadCount: newUnreadCount,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
print('删除通知失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新通知列表
|
||||
Future<void> refresh() async {
|
||||
await loadNotifications();
|
||||
await loadUnreadCount();
|
||||
}
|
||||
}
|
||||
|
||||
/// 通知Provider
|
||||
final notificationProvider =
|
||||
StateNotifierProvider<NotificationNotifier, NotificationState>(
|
||||
(ref) => NotificationNotifier(),
|
||||
);
|
||||
|
||||
/// 未读通知数量Provider(便捷访问)
|
||||
final unreadCountProvider = Provider<int>((ref) {
|
||||
return ref.watch(notificationProvider).unreadCount;
|
||||
});
|
||||
|
||||
/// 通知列表Provider(便捷访问)
|
||||
final notificationListProvider = Provider<List<NotificationModel>>((ref) {
|
||||
return ref.watch(notificationProvider).notifications;
|
||||
});
|
||||
369
client/lib/shared/providers/vocabulary_provider.dart
Normal file
369
client/lib/shared/providers/vocabulary_provider.dart
Normal file
@@ -0,0 +1,369 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/vocabulary_model.dart';
|
||||
import '../models/api_response.dart';
|
||||
import '../services/vocabulary_service.dart';
|
||||
import '../../core/errors/app_error.dart';
|
||||
|
||||
/// 词汇学习状态
|
||||
enum VocabularyState {
|
||||
initial,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
}
|
||||
|
||||
/// 词汇学习Provider
|
||||
class VocabularyProvider with ChangeNotifier {
|
||||
final VocabularyService _vocabularyService;
|
||||
|
||||
VocabularyProvider(this._vocabularyService);
|
||||
|
||||
// 状态管理
|
||||
VocabularyState _state = VocabularyState.initial;
|
||||
String? _errorMessage;
|
||||
|
||||
// 词库数据
|
||||
List<VocabularyBookModel> _vocabularyBooks = [];
|
||||
VocabularyBookModel? _currentBook;
|
||||
|
||||
// 词汇数据
|
||||
List<VocabularyModel> _vocabularies = [];
|
||||
List<UserVocabularyModel> _userVocabularies = [];
|
||||
VocabularyModel? _currentVocabulary;
|
||||
|
||||
// 学习数据
|
||||
List<VocabularyModel> _todayReviewWords = [];
|
||||
List<VocabularyModel> _newWords = [];
|
||||
Map<String, dynamic> _learningStats = {};
|
||||
|
||||
// 分页数据
|
||||
int _currentPage = 1;
|
||||
bool _hasMoreData = true;
|
||||
|
||||
// Getters
|
||||
VocabularyState get state => _state;
|
||||
String? get errorMessage => _errorMessage;
|
||||
List<VocabularyBookModel> get vocabularyBooks => _vocabularyBooks;
|
||||
VocabularyBookModel? get currentBook => _currentBook;
|
||||
List<VocabularyModel> get vocabularies => _vocabularies;
|
||||
List<UserVocabularyModel> get userVocabularies => _userVocabularies;
|
||||
VocabularyModel? get currentVocabulary => _currentVocabulary;
|
||||
List<VocabularyModel> get todayReviewWords => _todayReviewWords;
|
||||
List<VocabularyModel> get newWords => _newWords;
|
||||
Map<String, dynamic> get learningStats => _learningStats;
|
||||
int get currentPage => _currentPage;
|
||||
bool get hasMoreData => _hasMoreData;
|
||||
|
||||
/// 设置状态
|
||||
void _setState(VocabularyState newState, [String? error]) {
|
||||
_state = newState;
|
||||
_errorMessage = error;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 获取词库列表
|
||||
Future<void> loadVocabularyBooks() async {
|
||||
try {
|
||||
_setState(VocabularyState.loading);
|
||||
|
||||
final response = await _vocabularyService.getVocabularyBooks();
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
_vocabularyBooks = response.data!;
|
||||
_setState(VocabularyState.loaded);
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置当前词库
|
||||
void setCurrentBook(VocabularyBookModel book) {
|
||||
_currentBook = book;
|
||||
_vocabularies.clear();
|
||||
_currentPage = 1;
|
||||
_hasMoreData = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 获取词汇列表
|
||||
Future<void> loadVocabularies({
|
||||
String? bookId,
|
||||
String? search,
|
||||
String? difficulty,
|
||||
bool loadMore = false,
|
||||
}) async {
|
||||
try {
|
||||
if (!loadMore) {
|
||||
_setState(VocabularyState.loading);
|
||||
_currentPage = 1;
|
||||
_vocabularies.clear();
|
||||
}
|
||||
|
||||
final response = await _vocabularyService.getVocabularies(
|
||||
bookId: bookId ?? _currentBook?.bookId.toString(),
|
||||
page: _currentPage,
|
||||
search: search,
|
||||
level: difficulty,
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
final newVocabularies = response.data!.data;
|
||||
|
||||
if (loadMore) {
|
||||
_vocabularies.addAll(newVocabularies);
|
||||
} else {
|
||||
_vocabularies = newVocabularies;
|
||||
}
|
||||
|
||||
_hasMoreData = response.data!.pagination.hasNextPage;
|
||||
_currentPage++;
|
||||
_setState(VocabularyState.loaded);
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取单词详情
|
||||
Future<VocabularyModel?> getWordDetail(String wordId) async {
|
||||
try {
|
||||
final response = await _vocabularyService.getWordDetail(wordId);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
_currentVocabulary = response.data!;
|
||||
notifyListeners();
|
||||
return response.data!;
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取用户词汇学习记录
|
||||
Future<void> loadUserVocabularies(String userId) async {
|
||||
try {
|
||||
final response = await _vocabularyService.getUserVocabularies();
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
_userVocabularies = response.data!.data;
|
||||
notifyListeners();
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新单词学习状态
|
||||
Future<bool> updateWordStatus(String wordId, LearningStatus status) async {
|
||||
try {
|
||||
final response = await _vocabularyService.updateWordStatus(
|
||||
wordId: wordId,
|
||||
status: status,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
// 更新本地数据
|
||||
final index = _userVocabularies.indexWhere((uv) => uv.wordId.toString() == wordId);
|
||||
if (index != -1) {
|
||||
// TODO: 实现UserVocabularyModel的copyWith方法
|
||||
// _userVocabularies[index] = _userVocabularies[index].copyWith(
|
||||
// status: status,
|
||||
// lastStudiedAt: DateTime.now(),
|
||||
// );
|
||||
notifyListeners();
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加单词到学习列表
|
||||
Future<bool> addToLearningList(String wordId) async {
|
||||
try {
|
||||
// TODO: 实现addToLearningList方法
|
||||
// final response = await _vocabularyService.addToLearningList(wordId);
|
||||
final response = ApiResponse.success(message: 'Added to learning list');
|
||||
|
||||
if (response.success) {
|
||||
// 刷新用户词汇数据
|
||||
await loadUserVocabularies('current_user_id'); // TODO: 获取当前用户ID
|
||||
return true;
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 从学习列表移除单词
|
||||
Future<bool> removeFromLearningList(String wordId) async {
|
||||
try {
|
||||
// TODO: 实现removeFromLearningList方法
|
||||
// final response = await _vocabularyService.removeFromLearningList(wordId);
|
||||
final response = ApiResponse.success(message: 'Removed from learning list');
|
||||
|
||||
if (response.success) {
|
||||
// 更新本地数据
|
||||
_userVocabularies.removeWhere((uv) => uv.wordId.toString() == wordId);
|
||||
notifyListeners();
|
||||
return true;
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取今日复习单词
|
||||
Future<void> loadTodayReviewWords() async {
|
||||
try {
|
||||
// TODO: 实现getTodayReviewWords方法
|
||||
// final response = await _vocabularyService.getTodayReviewWords();
|
||||
final response = ApiResponse<List<VocabularyModel>>.success(
|
||||
message: 'Today review words retrieved',
|
||||
data: <VocabularyModel>[],
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
_todayReviewWords = response.data ?? [];
|
||||
notifyListeners();
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取新单词学习
|
||||
Future<void> loadNewWords({int limit = 20}) async {
|
||||
try {
|
||||
// TODO: 实现getNewWordsForLearning方法
|
||||
// final response = await _vocabularyService.getNewWordsForLearning(limit: limit);
|
||||
final response = ApiResponse<List<VocabularyModel>>.success(
|
||||
message: 'New words retrieved',
|
||||
data: <VocabularyModel>[],
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
_newWords = response.data ?? [];
|
||||
notifyListeners();
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 进行词汇量测试
|
||||
Future<Map<String, dynamic>?> takeVocabularyTest(List<Map<String, dynamic>> answers) async {
|
||||
try {
|
||||
_setState(VocabularyState.loading);
|
||||
|
||||
// TODO: 实现takeVocabularyTest方法
|
||||
// final response = await _vocabularyService.takeVocabularyTest(answers);
|
||||
final response = ApiResponse<Map<String, dynamic>>.success(
|
||||
message: 'Vocabulary test completed',
|
||||
data: <String, dynamic>{},
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
_setState(VocabularyState.loaded);
|
||||
return response.data!;
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取学习统计
|
||||
Future<void> loadLearningStats() async {
|
||||
try {
|
||||
// TODO: 实现getLearningStats方法
|
||||
// final response = await _vocabularyService.getLearningStats();
|
||||
final response = ApiResponse<Map<String, dynamic>>.success(
|
||||
message: 'Learning stats retrieved',
|
||||
data: <String, dynamic>{},
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
_learningStats = response.data ?? {};
|
||||
notifyListeners();
|
||||
} else {
|
||||
_setState(VocabularyState.error, response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
_setState(VocabularyState.error, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索词汇
|
||||
Future<void> searchVocabularies(String query) async {
|
||||
await loadVocabularies(search: query);
|
||||
}
|
||||
|
||||
/// 按难度筛选词汇
|
||||
Future<void> filterByDifficulty(String difficulty) async {
|
||||
await loadVocabularies(difficulty: difficulty);
|
||||
}
|
||||
|
||||
/// 加载更多词汇
|
||||
Future<void> loadMoreVocabularies() async {
|
||||
if (_hasMoreData && _state != VocabularyState.loading) {
|
||||
await loadVocabularies(loadMore: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除错误状态
|
||||
void clearError() {
|
||||
_errorMessage = null;
|
||||
if (_state == VocabularyState.error) {
|
||||
_setState(VocabularyState.initial);
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置状态
|
||||
void reset() {
|
||||
_state = VocabularyState.initial;
|
||||
_errorMessage = null;
|
||||
_vocabularyBooks.clear();
|
||||
_currentBook = null;
|
||||
_vocabularies.clear();
|
||||
_userVocabularies.clear();
|
||||
_currentVocabulary = null;
|
||||
_todayReviewWords.clear();
|
||||
_newWords.clear();
|
||||
_learningStats.clear();
|
||||
_currentPage = 1;
|
||||
_hasMoreData = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
332
client/lib/shared/services/audio_service.dart
Normal file
332
client/lib/shared/services/audio_service.dart
Normal file
@@ -0,0 +1,332 @@
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/errors/app_error.dart';
|
||||
import '../models/api_response.dart';
|
||||
|
||||
/// 音频播放状态
|
||||
enum AudioPlayerState {
|
||||
stopped,
|
||||
playing,
|
||||
paused,
|
||||
loading,
|
||||
error,
|
||||
}
|
||||
|
||||
/// 音频服务
|
||||
class AudioService {
|
||||
static final AudioService _instance = AudioService._internal();
|
||||
factory AudioService() => _instance;
|
||||
AudioService._internal();
|
||||
|
||||
final ApiClient _apiClient = ApiClient.instance;
|
||||
|
||||
AudioPlayerState _state = AudioPlayerState.stopped;
|
||||
Duration _duration = Duration.zero;
|
||||
Duration _position = Duration.zero;
|
||||
double _volume = 1.0;
|
||||
double _playbackRate = 1.0;
|
||||
String? _currentUrl;
|
||||
|
||||
// 回调函数
|
||||
Function(AudioPlayerState)? onStateChanged;
|
||||
Function(Duration)? onDurationChanged;
|
||||
Function(Duration)? onPositionChanged;
|
||||
Function(String)? onError;
|
||||
|
||||
/// 更新播放状态
|
||||
void _updateState(AudioPlayerState state) {
|
||||
_state = state;
|
||||
onStateChanged?.call(state);
|
||||
}
|
||||
|
||||
/// 播放网络音频
|
||||
Future<void> playFromUrl(String url) async {
|
||||
try {
|
||||
_updateState(AudioPlayerState.loading);
|
||||
_currentUrl = url;
|
||||
|
||||
// TODO: 实现音频播放逻辑
|
||||
_updateState(AudioPlayerState.playing);
|
||||
} catch (e) {
|
||||
_updateState(AudioPlayerState.error);
|
||||
onError?.call('播放失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放本地音频文件
|
||||
Future<void> playFromFile(String filePath) async {
|
||||
try {
|
||||
_updateState(AudioPlayerState.loading);
|
||||
_currentUrl = filePath;
|
||||
|
||||
// TODO: 实现本地音频播放逻辑
|
||||
_updateState(AudioPlayerState.playing);
|
||||
} catch (e) {
|
||||
_updateState(AudioPlayerState.error);
|
||||
onError?.call('播放失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放资源文件
|
||||
Future<void> playFromAsset(String assetPath) async {
|
||||
try {
|
||||
_updateState(AudioPlayerState.loading);
|
||||
_currentUrl = assetPath;
|
||||
|
||||
// TODO: 实现资源音频播放逻辑
|
||||
_updateState(AudioPlayerState.playing);
|
||||
} catch (e) {
|
||||
_updateState(AudioPlayerState.error);
|
||||
onError?.call('播放失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 暂停播放
|
||||
Future<void> pause() async {
|
||||
try {
|
||||
// TODO: 实现暂停逻辑
|
||||
_updateState(AudioPlayerState.paused);
|
||||
} catch (e) {
|
||||
onError?.call('暂停失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复播放
|
||||
Future<void> resume() async {
|
||||
try {
|
||||
// TODO: 实现恢复播放逻辑
|
||||
_updateState(AudioPlayerState.playing);
|
||||
} catch (e) {
|
||||
onError?.call('恢复播放失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止播放
|
||||
Future<void> stop() async {
|
||||
try {
|
||||
// TODO: 实现停止播放逻辑
|
||||
_updateState(AudioPlayerState.stopped);
|
||||
_position = Duration.zero;
|
||||
} catch (e) {
|
||||
onError?.call('停止播放失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 跳转到指定位置
|
||||
Future<void> seek(Duration position) async {
|
||||
try {
|
||||
// TODO: 实现跳转逻辑
|
||||
_position = position;
|
||||
} catch (e) {
|
||||
onError?.call('跳转失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置音量 (0.0 - 1.0)
|
||||
Future<void> setVolume(double volume) async {
|
||||
try {
|
||||
_volume = volume.clamp(0.0, 1.0);
|
||||
// TODO: 实现音量设置逻辑
|
||||
} catch (e) {
|
||||
onError?.call('设置音量失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置播放速度 (0.5 - 2.0)
|
||||
Future<void> setPlaybackRate(double rate) async {
|
||||
try {
|
||||
_playbackRate = rate.clamp(0.5, 2.0);
|
||||
// TODO: 实现播放速度设置逻辑
|
||||
} catch (e) {
|
||||
onError?.call('设置播放速度失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载音频文件
|
||||
Future<ApiResponse<String>> downloadAudio({
|
||||
required String url,
|
||||
required String fileName,
|
||||
Function(int, int)? onProgress,
|
||||
}) async {
|
||||
try {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final audioDir = Directory('${directory.path}/audio');
|
||||
|
||||
if (!await audioDir.exists()) {
|
||||
await audioDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final filePath = '${audioDir.path}/$fileName';
|
||||
|
||||
// TODO: 实现文件下载逻辑
|
||||
final response = await _apiClient.get(url);
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(response.data);
|
||||
|
||||
return ApiResponse.success(
|
||||
message: '音频下载成功',
|
||||
data: filePath,
|
||||
);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: '音频下载失败: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传音频文件
|
||||
Future<ApiResponse<Map<String, dynamic>>> uploadAudio({
|
||||
required String filePath,
|
||||
required String type, // 'pronunciation', 'speaking', etc.
|
||||
Map<String, dynamic>? metadata,
|
||||
Function(int, int)? onProgress,
|
||||
}) async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) {
|
||||
return ApiResponse.failure(
|
||||
message: '音频文件不存在',
|
||||
error: 'FILE_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
final formData = FormData.fromMap({
|
||||
'audio': await MultipartFile.fromFile(
|
||||
filePath,
|
||||
filename: file.path.split('/').last,
|
||||
),
|
||||
'type': type,
|
||||
if (metadata != null) 'metadata': metadata,
|
||||
});
|
||||
|
||||
final response = await _apiClient.post(
|
||||
'/audio/upload',
|
||||
data: formData,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return ApiResponse.success(
|
||||
message: response.data['message'] ?? '音频上传成功',
|
||||
data: response.data['data'],
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? '音频上传失败',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: '音频上传失败: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取音频文件信息
|
||||
Future<ApiResponse<Map<String, dynamic>>> getAudioInfo(String audioId) async {
|
||||
try {
|
||||
final response = await _apiClient.get('/audio/$audioId');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return ApiResponse.success(
|
||||
message: 'Audio info retrieved successfully',
|
||||
data: response.data['data'],
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to get audio info',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to get audio info: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除本地音频文件
|
||||
Future<bool> deleteLocalAudio(String filePath) async {
|
||||
try {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
onError?.call('删除音频文件失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理缓存的音频文件
|
||||
Future<void> clearAudioCache() async {
|
||||
try {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final audioDir = Directory('${directory.path}/audio');
|
||||
|
||||
if (await audioDir.exists()) {
|
||||
await audioDir.delete(recursive: true);
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.call('清理音频缓存失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查音频文件是否存在
|
||||
Future<bool> isAudioCached(String fileName) async {
|
||||
try {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final filePath = '${directory.path}/audio/$fileName';
|
||||
final file = File(filePath);
|
||||
return await file.exists();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取缓存音频文件路径
|
||||
Future<String?> getCachedAudioPath(String fileName) async {
|
||||
try {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final filePath = '${directory.path}/audio/$fileName';
|
||||
final file = File(filePath);
|
||||
|
||||
if (await file.exists()) {
|
||||
return filePath;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
Future<void> dispose() async {
|
||||
// TODO: 实现资源释放逻辑
|
||||
}
|
||||
|
||||
// Getters
|
||||
AudioPlayerState get state => _state;
|
||||
Duration get duration => _duration;
|
||||
Duration get position => _position;
|
||||
double get volume => _volume;
|
||||
double get playbackRate => _playbackRate;
|
||||
String? get currentUrl => _currentUrl;
|
||||
bool get isPlaying => _state == AudioPlayerState.playing;
|
||||
bool get isPaused => _state == AudioPlayerState.paused;
|
||||
bool get isStopped => _state == AudioPlayerState.stopped;
|
||||
bool get isLoading => _state == AudioPlayerState.loading;
|
||||
|
||||
/// 获取播放进度百分比 (0.0 - 1.0)
|
||||
double get progress {
|
||||
if (_duration.inMilliseconds == 0) return 0.0;
|
||||
return _position.inMilliseconds / _duration.inMilliseconds;
|
||||
}
|
||||
}
|
||||
371
client/lib/shared/services/auth_service.dart
Normal file
371
client/lib/shared/services/auth_service.dart
Normal file
@@ -0,0 +1,371 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/storage/storage_service.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/errors/app_error.dart';
|
||||
import '../models/api_response.dart';
|
||||
import '../models/user_model.dart';
|
||||
|
||||
/// 认证服务
|
||||
class AuthService {
|
||||
static final AuthService _instance = AuthService._internal();
|
||||
factory AuthService() => _instance;
|
||||
AuthService._internal();
|
||||
|
||||
final ApiClient _apiClient = ApiClient.instance;
|
||||
|
||||
/// 用户注册
|
||||
Future<ApiResponse<AuthResponse>> register({
|
||||
required String username,
|
||||
required String email,
|
||||
required String password,
|
||||
required String nickname,
|
||||
String? phone,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
'/auth/register',
|
||||
data: {
|
||||
'username': username,
|
||||
'email': email,
|
||||
'password': password,
|
||||
'nickname': nickname,
|
||||
if (phone != null) 'phone': phone,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
final authResponse = AuthResponse.fromJson(response.data['data']);
|
||||
await _saveTokens(authResponse);
|
||||
|
||||
return ApiResponse.success(
|
||||
message: response.data['message'] ?? '注册成功',
|
||||
data: authResponse,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? '注册失败',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: '注册失败:$e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户登录
|
||||
Future<ApiResponse<AuthResponse>> login({
|
||||
required String account, // 用户名或邮箱
|
||||
required String password,
|
||||
bool rememberMe = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
'/auth/login',
|
||||
data: {
|
||||
'account': account,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final authResponse = AuthResponse.fromJson(response.data['data']);
|
||||
await _saveTokens(authResponse);
|
||||
|
||||
return ApiResponse.success(
|
||||
message: response.data['message'] ?? '登录成功',
|
||||
data: authResponse,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? '登录失败',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: '登录失败:$e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新Token
|
||||
Future<ApiResponse<AuthResponse>> refreshToken() async {
|
||||
try {
|
||||
final refreshToken = StorageService.getString(AppConstants.refreshTokenKey);
|
||||
if (refreshToken == null) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Refresh token not found',
|
||||
code: 401,
|
||||
);
|
||||
}
|
||||
|
||||
final response = await _apiClient.post(
|
||||
'/auth/refresh',
|
||||
data: {
|
||||
'refresh_token': refreshToken,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final authResponse = AuthResponse.fromJson(response.data['data']);
|
||||
await _saveTokens(authResponse);
|
||||
|
||||
return ApiResponse.success(
|
||||
message: 'Token refreshed successfully',
|
||||
data: authResponse,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Token refresh failed',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Token refresh failed: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户登出
|
||||
Future<ApiResponse<void>> logout() async {
|
||||
try {
|
||||
final response = await _apiClient.post('/auth/logout');
|
||||
|
||||
// 无论服务器响应如何,都清除本地token
|
||||
await _clearTokens();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return ApiResponse.success(
|
||||
message: response.data['message'] ?? '登出成功',
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.success(
|
||||
message: '登出成功',
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
// 即使请求失败,也清除本地token
|
||||
await _clearTokens();
|
||||
return ApiResponse.success(
|
||||
message: '登出成功',
|
||||
);
|
||||
} catch (e) {
|
||||
await _clearTokens();
|
||||
return ApiResponse.success(
|
||||
message: '登出成功',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前用户信息
|
||||
Future<ApiResponse<UserModel>> getCurrentUser() async {
|
||||
try {
|
||||
final response = await _apiClient.get('/auth/me');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final user = UserModel.fromJson(response.data['data']);
|
||||
await StorageService.setObject(AppConstants.userInfoKey, user.toJson());
|
||||
|
||||
return ApiResponse.success(
|
||||
message: 'User info retrieved successfully',
|
||||
data: user,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to get user info',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to get user info: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新用户信息
|
||||
Future<ApiResponse<UserModel>> updateProfile({
|
||||
String? nickname,
|
||||
String? avatar,
|
||||
String? phone,
|
||||
DateTime? birthday,
|
||||
String? gender,
|
||||
String? bio,
|
||||
String? learningLevel,
|
||||
String? targetLanguage,
|
||||
String? nativeLanguage,
|
||||
int? dailyGoal,
|
||||
}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{};
|
||||
if (nickname != null) data['nickname'] = nickname;
|
||||
if (avatar != null) data['avatar'] = avatar;
|
||||
if (phone != null) data['phone'] = phone;
|
||||
if (birthday != null) data['birthday'] = birthday.toIso8601String();
|
||||
if (gender != null) data['gender'] = gender;
|
||||
if (bio != null) data['bio'] = bio;
|
||||
if (learningLevel != null) data['learning_level'] = learningLevel;
|
||||
if (targetLanguage != null) data['target_language'] = targetLanguage;
|
||||
if (nativeLanguage != null) data['native_language'] = nativeLanguage;
|
||||
if (dailyGoal != null) data['daily_goal'] = dailyGoal;
|
||||
|
||||
final response = await _apiClient.put('/auth/profile', data: data);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final user = UserModel.fromJson(response.data['data']);
|
||||
await StorageService.setObject(AppConstants.userInfoKey, user.toJson());
|
||||
|
||||
return ApiResponse.success(
|
||||
message: response.data['message'] ?? '个人信息更新成功',
|
||||
data: user,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? '个人信息更新失败',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: '个人信息更新失败:$e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 修改密码
|
||||
Future<ApiResponse<void>> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
required String confirmPassword,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.put(
|
||||
'/auth/change-password',
|
||||
data: {
|
||||
'current_password': currentPassword,
|
||||
'new_password': newPassword,
|
||||
'confirm_password': confirmPassword,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return ApiResponse.success(
|
||||
message: response.data['message'] ?? '密码修改成功',
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? '密码修改失败',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: '密码修改失败:$e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否已登录
|
||||
bool isLoggedIn() {
|
||||
final token = StorageService.getString(AppConstants.accessTokenKey);
|
||||
return token != null && token.isNotEmpty;
|
||||
}
|
||||
|
||||
/// 获取本地存储的用户信息
|
||||
UserModel? getCachedUser() {
|
||||
final userJson = StorageService.getObject(AppConstants.userInfoKey);
|
||||
if (userJson != null) {
|
||||
try {
|
||||
return UserModel.fromJson(userJson);
|
||||
} catch (e) {
|
||||
print('Error parsing cached user: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 保存tokens
|
||||
Future<void> _saveTokens(AuthResponse authResponse) async {
|
||||
await StorageService.setString(
|
||||
AppConstants.accessTokenKey,
|
||||
authResponse.accessToken,
|
||||
);
|
||||
await StorageService.setString(
|
||||
AppConstants.refreshTokenKey,
|
||||
authResponse.refreshToken,
|
||||
);
|
||||
|
||||
if (authResponse.user != null) {
|
||||
await StorageService.setObject(
|
||||
AppConstants.userInfoKey,
|
||||
authResponse.user!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除tokens
|
||||
Future<void> _clearTokens() async {
|
||||
await StorageService.remove(AppConstants.accessTokenKey);
|
||||
await StorageService.remove(AppConstants.refreshTokenKey);
|
||||
await StorageService.remove(AppConstants.userInfoKey);
|
||||
}
|
||||
|
||||
/// 处理Dio错误
|
||||
ApiResponse<T> _handleDioError<T>(DioException e) {
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return ApiResponse.failure(
|
||||
message: '请求超时,请检查网络连接',
|
||||
error: 'TIMEOUT',
|
||||
);
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = e.response?.statusCode;
|
||||
final message = e.response?.data?['message'] ?? '请求失败';
|
||||
return ApiResponse.failure(
|
||||
message: message,
|
||||
code: statusCode,
|
||||
error: 'BAD_RESPONSE',
|
||||
);
|
||||
case DioExceptionType.cancel:
|
||||
return ApiResponse.failure(
|
||||
message: '请求已取消',
|
||||
error: 'CANCELLED',
|
||||
);
|
||||
case DioExceptionType.connectionError:
|
||||
return ApiResponse.failure(
|
||||
message: '网络连接失败,请检查网络设置',
|
||||
error: 'CONNECTION_ERROR',
|
||||
);
|
||||
default:
|
||||
return ApiResponse.failure(
|
||||
message: '未知错误:${e.message}',
|
||||
error: 'UNKNOWN',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
client/lib/shared/services/notification_service.dart
Normal file
109
client/lib/shared/services/notification_service.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import '../../../core/models/api_response.dart';
|
||||
import '../../../core/services/enhanced_api_service.dart';
|
||||
import '../models/notification_model.dart';
|
||||
|
||||
/// 通知服务
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
final EnhancedApiService _enhancedApiService = EnhancedApiService();
|
||||
|
||||
// 缓存时长配置
|
||||
static const Duration _shortCacheDuration = Duration(seconds: 30);
|
||||
|
||||
/// 获取通知列表
|
||||
Future<ApiResponse<Map<String, dynamic>>> getNotifications({
|
||||
int page = 1,
|
||||
int limit = 10,
|
||||
bool onlyUnread = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<Map<String, dynamic>>(
|
||||
'/notifications',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'only_unread': onlyUnread,
|
||||
},
|
||||
cacheDuration: _shortCacheDuration,
|
||||
fromJson: (data) {
|
||||
final notifications = (data['notifications'] as List?)
|
||||
?.map((json) => NotificationModel.fromJson(json))
|
||||
.toList() ??
|
||||
[];
|
||||
return {
|
||||
'notifications': notifications,
|
||||
'total': data['total'] ?? 0,
|
||||
'page': data['page'] ?? 1,
|
||||
'limit': data['limit'] ?? 10,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('获取通知列表异常: $e');
|
||||
return ApiResponse.error(message: '获取通知列表失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取未读通知数量
|
||||
Future<ApiResponse<int>> getUnreadCount() async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<int>(
|
||||
'/notifications/unread-count',
|
||||
cacheDuration: _shortCacheDuration,
|
||||
fromJson: (data) => data['count'] ?? 0,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('获取未读通知数量异常: $e');
|
||||
return ApiResponse.error(message: '获取未读通知数量失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记通知为已读
|
||||
Future<ApiResponse<void>> markAsRead(int notificationId) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.put<void>(
|
||||
'/notifications/$notificationId/read',
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('标记通知已读异常: $e');
|
||||
return ApiResponse.error(message: '标记通知已读失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记所有通知为已读
|
||||
Future<ApiResponse<void>> markAllAsRead() async {
|
||||
try {
|
||||
final response = await _enhancedApiService.put<void>(
|
||||
'/notifications/read-all',
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('标记所有通知已读异常: $e');
|
||||
return ApiResponse.error(message: '标记所有通知已读失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除通知
|
||||
Future<ApiResponse<void>> deleteNotification(int notificationId) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.delete<void>(
|
||||
'/notifications/$notificationId',
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
print('删除通知异常: $e');
|
||||
return ApiResponse.error(message: '删除通知失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
106
client/lib/shared/services/study_plan_service.dart
Normal file
106
client/lib/shared/services/study_plan_service.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../shared/services/api_service.dart';
|
||||
|
||||
/// 学习计划服务
|
||||
class StudyPlanService {
|
||||
final ApiService _apiService;
|
||||
|
||||
StudyPlanService(this._apiService);
|
||||
|
||||
/// 创建学习计划
|
||||
Future<Map<String, dynamic>> createStudyPlan({
|
||||
required String planName,
|
||||
String? description,
|
||||
required int dailyGoal,
|
||||
String? bookId,
|
||||
required String startDate,
|
||||
String? endDate,
|
||||
String? remindTime,
|
||||
String? remindDays,
|
||||
}) async {
|
||||
final response = await _apiService.post(
|
||||
'/study-plans',
|
||||
data: {
|
||||
'plan_name': planName,
|
||||
'description': description,
|
||||
'daily_goal': dailyGoal,
|
||||
'book_id': bookId,
|
||||
'start_date': startDate,
|
||||
'end_date': endDate,
|
||||
'remind_time': remindTime,
|
||||
'remind_days': remindDays,
|
||||
},
|
||||
);
|
||||
return response['data'];
|
||||
}
|
||||
|
||||
/// 获取学习计划列表
|
||||
Future<List<dynamic>> getUserStudyPlans({String status = 'all'}) async {
|
||||
final response = await _apiService.get(
|
||||
'/study-plans',
|
||||
queryParameters: {'status': status},
|
||||
);
|
||||
return response['data']['plans'];
|
||||
}
|
||||
|
||||
/// 获取今日学习计划
|
||||
Future<List<dynamic>> getTodayStudyPlans() async {
|
||||
final response = await _apiService.get('/study-plans/today');
|
||||
return response['data']['plans'];
|
||||
}
|
||||
|
||||
/// 获取学习计划详情
|
||||
Future<Map<String, dynamic>> getStudyPlanByID(int planId) async {
|
||||
final response = await _apiService.get('/study-plans/$planId');
|
||||
return response['data'];
|
||||
}
|
||||
|
||||
/// 更新学习计划
|
||||
Future<Map<String, dynamic>> updateStudyPlan(
|
||||
int planId,
|
||||
Map<String, dynamic> updates,
|
||||
) async {
|
||||
final response = await _apiService.put('/study-plans/$planId', data: updates);
|
||||
return response['data'];
|
||||
}
|
||||
|
||||
/// 删除学习计划
|
||||
Future<void> deleteStudyPlan(int planId) async {
|
||||
await _apiService.delete('/study-plans/$planId');
|
||||
}
|
||||
|
||||
/// 更新计划状态
|
||||
Future<void> updatePlanStatus(int planId, String status) async {
|
||||
await _apiService.patch(
|
||||
'/study-plans/$planId/status',
|
||||
data: {'status': status},
|
||||
);
|
||||
}
|
||||
|
||||
/// 记录学习进度
|
||||
Future<void> recordStudyProgress(
|
||||
int planId, {
|
||||
required int wordsStudied,
|
||||
int studyDuration = 0,
|
||||
}) async {
|
||||
await _apiService.post(
|
||||
'/study-plans/$planId/progress',
|
||||
data: {
|
||||
'words_studied': wordsStudied,
|
||||
'study_duration': studyDuration,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取计划统计
|
||||
Future<Map<String, dynamic>> getStudyPlanStatistics(int planId) async {
|
||||
final response = await _apiService.get('/study-plans/$planId/statistics');
|
||||
return response['data'];
|
||||
}
|
||||
}
|
||||
|
||||
/// 学习计划服务提供者
|
||||
final studyPlanServiceProvider = Provider<StudyPlanService>((ref) {
|
||||
final apiService = ref.watch(apiServiceProvider);
|
||||
return StudyPlanService(apiService);
|
||||
});
|
||||
479
client/lib/shared/services/vocabulary_service.dart
Normal file
479
client/lib/shared/services/vocabulary_service.dart
Normal file
@@ -0,0 +1,479 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/errors/app_error.dart';
|
||||
import '../models/api_response.dart';
|
||||
import '../models/vocabulary_model.dart';
|
||||
|
||||
/// 词汇服务
|
||||
class VocabularyService {
|
||||
static final VocabularyService _instance = VocabularyService._internal();
|
||||
factory VocabularyService() => _instance;
|
||||
VocabularyService._internal();
|
||||
|
||||
final ApiClient _apiClient = ApiClient.instance;
|
||||
|
||||
/// 获取词库列表
|
||||
Future<ApiResponse<List<VocabularyBookModel>>> getVocabularyBooks({
|
||||
String? category,
|
||||
String? level,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
};
|
||||
|
||||
if (category != null) queryParams['category'] = category;
|
||||
if (level != null) queryParams['level'] = level;
|
||||
|
||||
final response = await _apiClient.get(
|
||||
'/vocabulary/books',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> booksJson = response.data['data']['items'];
|
||||
final books = booksJson
|
||||
.map((json) => VocabularyBookModel.fromJson(json))
|
||||
.toList();
|
||||
|
||||
return ApiResponse.success(
|
||||
message: 'Vocabulary books retrieved successfully',
|
||||
data: books,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to get vocabulary books',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to get vocabulary books: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取词汇列表
|
||||
Future<ApiResponse<PaginatedResponse<VocabularyModel>>> getVocabularies({
|
||||
String? bookId,
|
||||
String? search,
|
||||
String? level,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
};
|
||||
|
||||
if (bookId != null) queryParams['book_id'] = bookId;
|
||||
if (search != null) queryParams['search'] = search;
|
||||
if (level != null) queryParams['level'] = level;
|
||||
|
||||
final response = await _apiClient.get(
|
||||
'/vocabulary/words',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final paginatedResponse = PaginatedResponse<VocabularyModel>.fromJson(
|
||||
response.data['data'],
|
||||
(json) => VocabularyModel.fromJson(json),
|
||||
);
|
||||
|
||||
return ApiResponse.success(
|
||||
message: 'Vocabularies retrieved successfully',
|
||||
data: paginatedResponse,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to get vocabularies',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to get vocabularies: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取单词详情
|
||||
Future<ApiResponse<VocabularyModel>> getWordDetail(String wordId) async {
|
||||
try {
|
||||
final response = await _apiClient.get('/vocabulary/words/$wordId');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final word = VocabularyModel.fromJson(response.data['data']);
|
||||
|
||||
return ApiResponse.success(
|
||||
message: 'Word detail retrieved successfully',
|
||||
data: word,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to get word detail',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to get word detail: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取用户词汇学习记录
|
||||
Future<ApiResponse<PaginatedResponse<UserVocabularyModel>>> getUserVocabularies({
|
||||
LearningStatus? status,
|
||||
String? bookId,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
};
|
||||
|
||||
if (status != null) queryParams['status'] = status.name;
|
||||
if (bookId != null) queryParams['book_id'] = bookId;
|
||||
|
||||
final response = await _apiClient.get(
|
||||
'/vocabulary/user-words',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final paginatedResponse = PaginatedResponse<UserVocabularyModel>.fromJson(
|
||||
response.data['data'],
|
||||
(json) => UserVocabularyModel.fromJson(json),
|
||||
);
|
||||
|
||||
return ApiResponse.success(
|
||||
message: 'User vocabularies retrieved successfully',
|
||||
data: paginatedResponse,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to get user vocabularies',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to get user vocabularies: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新单词学习状态
|
||||
Future<ApiResponse<UserVocabularyModel>> updateWordStatus({
|
||||
required String wordId,
|
||||
required LearningStatus status,
|
||||
int? correctCount,
|
||||
int? wrongCount,
|
||||
int? reviewCount,
|
||||
DateTime? nextReviewDate,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{
|
||||
'status': status.name,
|
||||
};
|
||||
|
||||
if (correctCount != null) data['correct_count'] = correctCount;
|
||||
if (wrongCount != null) data['wrong_count'] = wrongCount;
|
||||
if (reviewCount != null) data['review_count'] = reviewCount;
|
||||
if (nextReviewDate != null) {
|
||||
data['next_review_date'] = nextReviewDate.toIso8601String();
|
||||
}
|
||||
if (metadata != null) data['metadata'] = metadata;
|
||||
|
||||
final response = await _apiClient.put(
|
||||
'/vocabulary/user-words/$wordId',
|
||||
data: data,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final userWord = UserVocabularyModel.fromJson(response.data['data']);
|
||||
|
||||
return ApiResponse.success(
|
||||
message: response.data['message'] ?? 'Word status updated successfully',
|
||||
data: userWord,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to update word status',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to update word status: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加单词到学习列表
|
||||
Future<ApiResponse<UserVocabularyModel>> addWordToLearning(String wordId) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
'/vocabulary/user-words',
|
||||
data: {'word_id': wordId},
|
||||
);
|
||||
|
||||
if (response.statusCode == 201) {
|
||||
final userWord = UserVocabularyModel.fromJson(response.data['data']);
|
||||
|
||||
return ApiResponse.success(
|
||||
message: response.data['message'] ?? 'Word added to learning list',
|
||||
data: userWord,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to add word to learning list',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to add word to learning list: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 从学习列表移除单词
|
||||
Future<ApiResponse<void>> removeWordFromLearning(String wordId) async {
|
||||
try {
|
||||
final response = await _apiClient.delete('/vocabulary/user-words/$wordId');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return ApiResponse.success(
|
||||
message: response.data['message'] ?? 'Word removed from learning list',
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to remove word from learning list',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to remove word from learning list: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取今日复习单词
|
||||
Future<ApiResponse<List<UserVocabularyModel>>> getTodayReviewWords() async {
|
||||
try {
|
||||
final response = await _apiClient.get('/vocabulary/today-review');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> wordsJson = response.data['data'];
|
||||
final words = wordsJson
|
||||
.map((json) => UserVocabularyModel.fromJson(json))
|
||||
.toList();
|
||||
|
||||
return ApiResponse.success(
|
||||
message: 'Today review words retrieved successfully',
|
||||
data: words,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to get today review words',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to get today review words: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取新单词学习
|
||||
Future<ApiResponse<List<VocabularyModel>>> getNewWordsForLearning({
|
||||
String? bookId,
|
||||
int limit = 10,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'limit': limit,
|
||||
};
|
||||
|
||||
if (bookId != null) queryParams['book_id'] = bookId;
|
||||
|
||||
final response = await _apiClient.get(
|
||||
'/vocabulary/new-words',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> wordsJson = response.data['data'];
|
||||
final words = wordsJson
|
||||
.map((json) => VocabularyModel.fromJson(json))
|
||||
.toList();
|
||||
|
||||
return ApiResponse.success(
|
||||
message: 'New words for learning retrieved successfully',
|
||||
data: words,
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to get new words for learning',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to get new words for learning: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 词汇量测试
|
||||
Future<ApiResponse<Map<String, dynamic>>> vocabularyTest({
|
||||
required List<String> wordIds,
|
||||
required List<String> answers,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
'/vocabulary/test',
|
||||
data: {
|
||||
'word_ids': wordIds,
|
||||
'answers': answers,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return ApiResponse.success(
|
||||
message: 'Vocabulary test completed successfully',
|
||||
data: response.data['data'],
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Vocabulary test failed',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Vocabulary test failed: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取学习统计
|
||||
Future<ApiResponse<Map<String, dynamic>>> getLearningStats({
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{};
|
||||
|
||||
if (startDate != null) {
|
||||
queryParams['start_date'] = startDate.toIso8601String();
|
||||
}
|
||||
if (endDate != null) {
|
||||
queryParams['end_date'] = endDate.toIso8601String();
|
||||
}
|
||||
|
||||
final response = await _apiClient.get(
|
||||
'/vocabulary/stats',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return ApiResponse.success(
|
||||
message: 'Learning stats retrieved successfully',
|
||||
data: response.data['data'],
|
||||
);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
message: response.data['message'] ?? 'Failed to get learning stats',
|
||||
code: response.statusCode,
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
return _handleDioError(e);
|
||||
} catch (e) {
|
||||
return ApiResponse.failure(
|
||||
message: 'Failed to get learning stats: $e',
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理Dio错误
|
||||
ApiResponse<T> _handleDioError<T>(DioException e) {
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return ApiResponse.failure(
|
||||
message: '请求超时,请检查网络连接',
|
||||
error: 'TIMEOUT',
|
||||
);
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = e.response?.statusCode;
|
||||
final message = e.response?.data?['message'] ?? '请求失败';
|
||||
return ApiResponse.failure(
|
||||
message: message,
|
||||
code: statusCode,
|
||||
error: 'BAD_RESPONSE',
|
||||
);
|
||||
case DioExceptionType.cancel:
|
||||
return ApiResponse.failure(
|
||||
message: '请求已取消',
|
||||
error: 'CANCELLED',
|
||||
);
|
||||
case DioExceptionType.connectionError:
|
||||
return ApiResponse.failure(
|
||||
message: '网络连接失败,请检查网络设置',
|
||||
error: 'CONNECTION_ERROR',
|
||||
);
|
||||
default:
|
||||
return ApiResponse.failure(
|
||||
message: '未知错误:${e.message}',
|
||||
error: 'UNKNOWN',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
client/lib/shared/services/word_book_service.dart
Normal file
65
client/lib/shared/services/word_book_service.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
|
||||
/// 生词本服务
|
||||
class WordBookService {
|
||||
final ApiClient _apiClient = ApiClient.instance;
|
||||
|
||||
WordBookService();
|
||||
|
||||
/// 切换单词收藏状态
|
||||
Future<Map<String, dynamic>> toggleFavorite(int wordId) async {
|
||||
final response = await _apiClient.post('/word-book/toggle/$wordId');
|
||||
return response.data['data'];
|
||||
}
|
||||
|
||||
/// 获取生词本列表
|
||||
Future<Map<String, dynamic>> getFavoriteWords({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String sortBy = 'created_at',
|
||||
String order = 'desc',
|
||||
}) async {
|
||||
final response = await _apiClient.get(
|
||||
'/word-book',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
'sort_by': sortBy,
|
||||
'order': order,
|
||||
},
|
||||
);
|
||||
return response.data['data'];
|
||||
}
|
||||
|
||||
/// 获取指定词汇书的生词本
|
||||
Future<List<dynamic>> getFavoriteWordsByBook(String bookId) async {
|
||||
final response = await _apiClient.get('/word-book/books/$bookId');
|
||||
return response.data['data']['words'];
|
||||
}
|
||||
|
||||
/// 获取生词本统计信息
|
||||
Future<Map<String, dynamic>> getFavoriteStats() async {
|
||||
final response = await _apiClient.get('/word-book/stats');
|
||||
return response.data['data'];
|
||||
}
|
||||
|
||||
/// 批量添加到生词本
|
||||
Future<int> batchAddToFavorite(List<int> wordIds) async {
|
||||
final response = await _apiClient.post(
|
||||
'/word-book/batch',
|
||||
data: {'word_ids': wordIds},
|
||||
);
|
||||
return response.data['data']['added_count'];
|
||||
}
|
||||
|
||||
/// 从生词本移除单词
|
||||
Future<void> removeFromFavorite(int wordId) async {
|
||||
await _apiClient.delete('/word-book/$wordId');
|
||||
}
|
||||
}
|
||||
|
||||
/// 生词本服务提供者
|
||||
final wordBookServiceProvider = Provider<WordBookService>((ref) {
|
||||
return WordBookService();
|
||||
});
|
||||
245
client/lib/shared/widgets/custom_app_bar.dart
Normal file
245
client/lib/shared/widgets/custom_app_bar.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 自定义应用栏
|
||||
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool centerTitle;
|
||||
final Color? backgroundColor;
|
||||
final Color? foregroundColor;
|
||||
final double elevation;
|
||||
final bool automaticallyImplyLeading;
|
||||
final PreferredSizeWidget? bottom;
|
||||
final VoidCallback? onBackPressed;
|
||||
|
||||
const CustomAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.centerTitle = true,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.elevation = 0,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.bottom,
|
||||
this.onBackPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AppBar(
|
||||
title: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: foregroundColor ?? theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
centerTitle: centerTitle,
|
||||
backgroundColor: backgroundColor ?? theme.colorScheme.surface,
|
||||
foregroundColor: foregroundColor ?? theme.colorScheme.onSurface,
|
||||
elevation: elevation,
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
leading: leading ?? (onBackPressed != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: onBackPressed,
|
||||
)
|
||||
: null),
|
||||
actions: actions,
|
||||
bottom: bottom,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size.fromHeight(
|
||||
kToolbarHeight + (bottom?.preferredSize.height ?? 0.0),
|
||||
);
|
||||
}
|
||||
|
||||
/// 带搜索功能的应用栏
|
||||
class SearchAppBar extends StatefulWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final String hintText;
|
||||
final ValueChanged<String>? onSearchChanged;
|
||||
final VoidCallback? onSearchSubmitted;
|
||||
final List<Widget>? actions;
|
||||
final bool automaticallyImplyLeading;
|
||||
final VoidCallback? onBackPressed;
|
||||
|
||||
const SearchAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.hintText = '搜索...',
|
||||
this.onSearchChanged,
|
||||
this.onSearchSubmitted,
|
||||
this.actions,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.onBackPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SearchAppBar> createState() => _SearchAppBarState();
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
class _SearchAppBarState extends State<SearchAppBar> {
|
||||
bool _isSearching = false;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startSearch() {
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
});
|
||||
_searchFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _stopSearch() {
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_searchController.clear();
|
||||
});
|
||||
_searchFocusNode.unfocus();
|
||||
widget.onSearchChanged?.call('');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AppBar(
|
||||
title: _isSearching
|
||||
? TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
border: InputBorder.none,
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
onChanged: widget.onSearchChanged,
|
||||
onSubmitted: (_) => widget.onSearchSubmitted?.call(),
|
||||
)
|
||||
: Text(
|
||||
widget.title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: !_isSearching,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
foregroundColor: theme.colorScheme.onSurface,
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: widget.automaticallyImplyLeading && !_isSearching,
|
||||
leading: _isSearching
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _stopSearch,
|
||||
)
|
||||
: (widget.onBackPressed != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: widget.onBackPressed,
|
||||
)
|
||||
: null),
|
||||
actions: _isSearching
|
||||
? [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
widget.onSearchChanged?.call('');
|
||||
},
|
||||
),
|
||||
]
|
||||
: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: _startSearch,
|
||||
),
|
||||
...?widget.actions,
|
||||
],
|
||||
surfaceTintColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 带标签页的应用栏
|
||||
class TabAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final List<Tab> tabs;
|
||||
final TabController? controller;
|
||||
final List<Widget>? actions;
|
||||
final bool automaticallyImplyLeading;
|
||||
final VoidCallback? onBackPressed;
|
||||
|
||||
const TabAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.tabs,
|
||||
this.controller,
|
||||
this.actions,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.onBackPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AppBar(
|
||||
title: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
foregroundColor: theme.colorScheme.onSurface,
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: automaticallyImplyLeading,
|
||||
leading: onBackPressed != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: onBackPressed,
|
||||
)
|
||||
: null,
|
||||
actions: actions,
|
||||
bottom: TabBar(
|
||||
controller: controller,
|
||||
tabs: tabs,
|
||||
labelColor: theme.colorScheme.primary,
|
||||
unselectedLabelColor: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
indicatorColor: theme.colorScheme.primary,
|
||||
indicatorWeight: 2,
|
||||
labelStyle: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedLabelStyle: theme.textTheme.titleSmall,
|
||||
),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight + kTextTabBarHeight);
|
||||
}
|
||||
440
client/lib/shared/widgets/custom_card.dart
Normal file
440
client/lib/shared/widgets/custom_card.dart
Normal file
@@ -0,0 +1,440 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 自定义卡片组件
|
||||
class CustomCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Color? color;
|
||||
final double? elevation;
|
||||
final BorderRadius? borderRadius;
|
||||
final Border? border;
|
||||
final VoidCallback? onTap;
|
||||
final bool isSelected;
|
||||
final Color? selectedColor;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxShadow? shadow;
|
||||
|
||||
const CustomCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.color,
|
||||
this.elevation,
|
||||
this.borderRadius,
|
||||
this.border,
|
||||
this.onTap,
|
||||
this.isSelected = false,
|
||||
this.selectedColor,
|
||||
this.width,
|
||||
this.height,
|
||||
this.shadow,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final defaultBorderRadius = BorderRadius.circular(12);
|
||||
|
||||
Widget card = Container(
|
||||
width: width,
|
||||
height: height,
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (selectedColor ?? theme.colorScheme.primaryContainer)
|
||||
: (color ?? theme.colorScheme.surface),
|
||||
borderRadius: borderRadius ?? defaultBorderRadius,
|
||||
border: border ?? (isSelected
|
||||
? Border.all(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
)
|
||||
: Border.all(
|
||||
color: theme.colorScheme.outline.withOpacity(0.2),
|
||||
width: 1,
|
||||
)),
|
||||
boxShadow: shadow != null
|
||||
? [shadow!]
|
||||
: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.shadow.withOpacity(0.1),
|
||||
blurRadius: elevation ?? 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: borderRadius ?? defaultBorderRadius,
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(16),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
/// 信息卡片
|
||||
class InfoCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Color? backgroundColor;
|
||||
final bool isSelected;
|
||||
|
||||
const InfoCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.backgroundColor,
|
||||
this.isSelected = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return CustomCard(
|
||||
onTap: onTap,
|
||||
padding: padding ?? const EdgeInsets.all(16),
|
||||
margin: margin,
|
||||
color: backgroundColor,
|
||||
isSelected: isSelected,
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
leading!,
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: isSelected
|
||||
? theme.colorScheme.onPrimaryContainer.withOpacity(0.8)
|
||||
: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计卡片
|
||||
class StatCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final String? unit;
|
||||
final IconData? icon;
|
||||
final Color? iconColor;
|
||||
final Color? backgroundColor;
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Widget? trailing;
|
||||
|
||||
const StatCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.unit,
|
||||
this.icon,
|
||||
this.iconColor,
|
||||
this.backgroundColor,
|
||||
this.onTap,
|
||||
this.margin,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return CustomCard(
|
||||
onTap: onTap,
|
||||
margin: margin,
|
||||
color: backgroundColor,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: iconColor ?? theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (unit != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
unit!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 功能卡片
|
||||
class FeatureCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String? description;
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final Color? backgroundColor;
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final bool isEnabled;
|
||||
final Widget? badge;
|
||||
|
||||
const FeatureCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.icon,
|
||||
this.iconColor,
|
||||
this.backgroundColor,
|
||||
this.onTap,
|
||||
this.margin,
|
||||
this.isEnabled = true,
|
||||
this.badge,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return CustomCard(
|
||||
onTap: isEnabled ? onTap : null,
|
||||
margin: margin,
|
||||
color: backgroundColor,
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: (iconColor ?? theme.colorScheme.primary).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: isEnabled
|
||||
? (iconColor ?? theme.colorScheme.primary)
|
||||
: theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isEnabled
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isEnabled
|
||||
? theme.colorScheme.onSurface.withOpacity(0.7)
|
||||
: theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (badge != null)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: badge!,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度卡片
|
||||
class ProgressCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final double progress;
|
||||
final String? progressText;
|
||||
final Color? progressColor;
|
||||
final Color? backgroundColor;
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Widget? trailing;
|
||||
|
||||
const ProgressCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.progress,
|
||||
this.progressText,
|
||||
this.progressColor,
|
||||
this.backgroundColor,
|
||||
this.onTap,
|
||||
this.margin,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return CustomCard(
|
||||
onTap: onTap,
|
||||
margin: margin,
|
||||
color: backgroundColor,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: progress.clamp(0.0, 1.0),
|
||||
backgroundColor: theme.colorScheme.surfaceVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
progressColor ?? theme.colorScheme.primary,
|
||||
),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
if (progressText != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
progressText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
243
client/lib/shared/widgets/error_handler.dart
Normal file
243
client/lib/shared/widgets/error_handler.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/error_provider.dart';
|
||||
|
||||
/// 全局错误处理组件
|
||||
class GlobalErrorHandler extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
const GlobalErrorHandler({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final errorState = ref.watch(errorProvider);
|
||||
|
||||
// 监听错误状态变化
|
||||
ref.listen<ErrorState>(errorProvider, (previous, next) {
|
||||
if (next.currentError != null) {
|
||||
_showErrorDialog(context, next.currentError!, ref);
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
void _showErrorDialog(BuildContext context, AppError error, WidgetRef ref) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => ErrorDialog(
|
||||
error: error,
|
||||
onDismiss: () {
|
||||
Navigator.of(context).pop();
|
||||
ref.read(errorProvider.notifier).removeError(error.id);
|
||||
},
|
||||
onRetry: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误对话框
|
||||
class ErrorDialog extends StatelessWidget {
|
||||
final AppError error;
|
||||
final VoidCallback onDismiss;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const ErrorDialog({
|
||||
super.key,
|
||||
required this.error,
|
||||
required this.onDismiss,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getErrorIcon(),
|
||||
color: _getErrorColor(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(_getErrorTitle()),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(error.message),
|
||||
if (error.details != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.details!,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (onRetry != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onDismiss();
|
||||
onRetry!();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onDismiss,
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getErrorIcon() {
|
||||
switch (error.type) {
|
||||
case ErrorType.network:
|
||||
return Icons.wifi_off;
|
||||
case ErrorType.authentication:
|
||||
return Icons.lock;
|
||||
case ErrorType.validation:
|
||||
return Icons.warning;
|
||||
case ErrorType.server:
|
||||
return Icons.error;
|
||||
case ErrorType.unknown:
|
||||
default:
|
||||
return Icons.help_outline;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getErrorColor() {
|
||||
switch (error.severity) {
|
||||
case ErrorSeverity.critical:
|
||||
return Colors.red;
|
||||
case ErrorSeverity.error:
|
||||
return Colors.orange;
|
||||
case ErrorSeverity.warning:
|
||||
return Colors.yellow[700]!;
|
||||
case ErrorSeverity.info:
|
||||
return Colors.blue;
|
||||
}
|
||||
}
|
||||
|
||||
String _getErrorTitle() {
|
||||
switch (error.type) {
|
||||
case ErrorType.network:
|
||||
return '网络错误';
|
||||
case ErrorType.authentication:
|
||||
return '认证错误';
|
||||
case ErrorType.validation:
|
||||
return '验证错误';
|
||||
case ErrorType.server:
|
||||
return '服务器错误';
|
||||
case ErrorType.unknown:
|
||||
default:
|
||||
return '未知错误';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误横幅组件
|
||||
class ErrorBanner extends ConsumerWidget {
|
||||
const ErrorBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final errorState = ref.watch(errorProvider);
|
||||
|
||||
if (!errorState.hasErrors) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final lowSeverityErrors = errorState.errors
|
||||
.where((error) => error.severity == ErrorSeverity.info)
|
||||
.toList();
|
||||
|
||||
if (lowSeverityErrors.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: Colors.blue[50],
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Colors.blue[700],
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
lowSeverityErrors.first.message,
|
||||
style: TextStyle(
|
||||
color: Colors.blue[700],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(errorProvider.notifier).removeError(lowSeverityErrors.first.id);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Colors.blue[700],
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误重试组件
|
||||
class ErrorRetryWidget extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
final IconData? icon;
|
||||
|
||||
const ErrorRetryWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
412
client/lib/shared/widgets/error_widget.dart
Normal file
412
client/lib/shared/widgets/error_widget.dart
Normal file
@@ -0,0 +1,412 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 错误显示组件
|
||||
class ErrorDisplayWidget extends StatelessWidget {
|
||||
final String message;
|
||||
final String? title;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onRetry;
|
||||
final String? retryText;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final bool showIcon;
|
||||
final Color? iconColor;
|
||||
final TextAlign textAlign;
|
||||
|
||||
const ErrorDisplayWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.title,
|
||||
this.icon,
|
||||
this.onRetry,
|
||||
this.retryText,
|
||||
this.padding,
|
||||
this.showIcon = true,
|
||||
this.iconColor,
|
||||
this.textAlign = TextAlign.center,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: padding ?? const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showIcon) ...[
|
||||
Icon(
|
||||
icon ?? Icons.error_outline,
|
||||
size: 64,
|
||||
color: iconColor ?? theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
textAlign: textAlign,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: textAlign,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(retryText ?? '重试'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面错误组件
|
||||
class PageErrorWidget extends StatelessWidget {
|
||||
final String message;
|
||||
final String? title;
|
||||
final VoidCallback? onRetry;
|
||||
final bool showAppBar;
|
||||
final String? appBarTitle;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
const PageErrorWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.title,
|
||||
this.onRetry,
|
||||
this.showAppBar = false,
|
||||
this.appBarTitle,
|
||||
this.onBack,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: showAppBar
|
||||
? AppBar(
|
||||
title: appBarTitle != null ? Text(appBarTitle!) : null,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
elevation: 0,
|
||||
leading: onBack != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: onBack,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
: null,
|
||||
body: Center(
|
||||
child: ErrorDisplayWidget(
|
||||
title: title ?? '出错了',
|
||||
message: message,
|
||||
onRetry: onRetry,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络错误组件
|
||||
class NetworkErrorWidget extends StatelessWidget {
|
||||
final VoidCallback? onRetry;
|
||||
final String? customMessage;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const NetworkErrorWidget({
|
||||
super.key,
|
||||
this.onRetry,
|
||||
this.customMessage,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ErrorDisplayWidget(
|
||||
title: '网络连接失败',
|
||||
message: customMessage ?? '请检查网络连接后重试',
|
||||
icon: Icons.wifi_off,
|
||||
onRetry: onRetry,
|
||||
padding: padding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 空数据组件
|
||||
class EmptyDataWidget extends StatelessWidget {
|
||||
final String message;
|
||||
final String? title;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onAction;
|
||||
final String? actionText;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final Color? iconColor;
|
||||
|
||||
const EmptyDataWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.title,
|
||||
this.icon,
|
||||
this.onAction,
|
||||
this.actionText,
|
||||
this.padding,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: padding ?? const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.inbox_outlined,
|
||||
size: 64,
|
||||
color: iconColor ?? theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onAction != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onAction,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(actionText ?? '添加'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.primary,
|
||||
side: BorderSide(color: theme.colorScheme.primary),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索无结果组件
|
||||
class NoSearchResultWidget extends StatelessWidget {
|
||||
final String query;
|
||||
final VoidCallback? onClear;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const NoSearchResultWidget({
|
||||
super.key,
|
||||
required this.query,
|
||||
this.onClear,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return EmptyDataWidget(
|
||||
title: '未找到相关结果',
|
||||
message: '没有找到与"$query"相关的内容\n请尝试其他关键词',
|
||||
icon: Icons.search_off,
|
||||
onAction: onClear,
|
||||
actionText: '清除搜索',
|
||||
padding: padding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限错误组件
|
||||
class PermissionErrorWidget extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback? onRequestPermission;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const PermissionErrorWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.onRequestPermission,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ErrorDisplayWidget(
|
||||
title: '权限不足',
|
||||
message: message,
|
||||
icon: Icons.lock_outline,
|
||||
onRetry: onRequestPermission,
|
||||
retryText: '授权',
|
||||
padding: padding,
|
||||
iconColor: Theme.of(context).colorScheme.warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 服务器错误组件
|
||||
class ServerErrorWidget extends StatelessWidget {
|
||||
final String? customMessage;
|
||||
final VoidCallback? onRetry;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const ServerErrorWidget({
|
||||
super.key,
|
||||
this.customMessage,
|
||||
this.onRetry,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ErrorDisplayWidget(
|
||||
title: '服务器错误',
|
||||
message: customMessage ?? '服务器暂时无法响应,请稍后重试',
|
||||
icon: Icons.cloud_off,
|
||||
onRetry: onRetry,
|
||||
padding: padding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用错误处理器
|
||||
class ErrorHandler {
|
||||
static Widget handleError(
|
||||
Object error, {
|
||||
VoidCallback? onRetry,
|
||||
EdgeInsetsGeometry? padding,
|
||||
}) {
|
||||
if (error.toString().contains('network') ||
|
||||
error.toString().contains('connection')) {
|
||||
return NetworkErrorWidget(
|
||||
onRetry: onRetry,
|
||||
padding: padding,
|
||||
);
|
||||
}
|
||||
|
||||
if (error.toString().contains('permission')) {
|
||||
return PermissionErrorWidget(
|
||||
message: error.toString(),
|
||||
onRequestPermission: onRetry,
|
||||
padding: padding,
|
||||
);
|
||||
}
|
||||
|
||||
if (error.toString().contains('server') ||
|
||||
error.toString().contains('500')) {
|
||||
return ServerErrorWidget(
|
||||
onRetry: onRetry,
|
||||
padding: padding,
|
||||
);
|
||||
}
|
||||
|
||||
return ErrorDisplayWidget(
|
||||
message: error.toString(),
|
||||
onRetry: onRetry,
|
||||
padding: padding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误边界组件
|
||||
class ErrorBoundary extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Widget Function(Object error)? errorBuilder;
|
||||
final void Function(Object error, StackTrace stackTrace)? onError;
|
||||
|
||||
const ErrorBoundary({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.errorBuilder,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ErrorBoundary> createState() => _ErrorBoundaryState();
|
||||
}
|
||||
|
||||
class _ErrorBoundaryState extends State<ErrorBoundary> {
|
||||
Object? _error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null) {
|
||||
return widget.errorBuilder?.call(_error!) ??
|
||||
ErrorHandler.handleError(_error!);
|
||||
}
|
||||
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// 重置错误状态
|
||||
if (_error != null) {
|
||||
setState(() {
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleError(Object error, StackTrace stackTrace) {
|
||||
widget.onError?.call(error, stackTrace);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 扩展 ColorScheme 以支持警告颜色
|
||||
extension ColorSchemeExtension on ColorScheme {
|
||||
Color get warning => const Color(0xFFFF9800);
|
||||
Color get onWarning => const Color(0xFF000000);
|
||||
}
|
||||
390
client/lib/shared/widgets/loading_widget.dart
Normal file
390
client/lib/shared/widgets/loading_widget.dart
Normal file
@@ -0,0 +1,390 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 加载组件
|
||||
class LoadingWidget extends StatelessWidget {
|
||||
final String? message;
|
||||
final double? size;
|
||||
final Color? color;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final bool showMessage;
|
||||
|
||||
const LoadingWidget({
|
||||
super.key,
|
||||
this.message,
|
||||
this.size,
|
||||
this.color,
|
||||
this.padding,
|
||||
this.showMessage = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: padding ?? const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size ?? 32,
|
||||
height: size ?? 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
color ?? theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showMessage && message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面加载组件
|
||||
class PageLoadingWidget extends StatelessWidget {
|
||||
final String? message;
|
||||
final bool showAppBar;
|
||||
final String? title;
|
||||
|
||||
const PageLoadingWidget({
|
||||
super.key,
|
||||
this.message,
|
||||
this.showAppBar = false,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: showAppBar
|
||||
? AppBar(
|
||||
title: title != null ? Text(title!) : null,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
elevation: 0,
|
||||
)
|
||||
: null,
|
||||
body: Center(
|
||||
child: LoadingWidget(
|
||||
message: message ?? '加载中...',
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表加载组件
|
||||
class ListLoadingWidget extends StatelessWidget {
|
||||
final int itemCount;
|
||||
final double itemHeight;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
const ListLoadingWidget({
|
||||
super.key,
|
||||
this.itemCount = 5,
|
||||
this.itemHeight = 80,
|
||||
this.padding,
|
||||
this.margin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
padding: padding,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) => Container(
|
||||
height: itemHeight,
|
||||
margin: margin ?? const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: const ShimmerWidget(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 骨架屏组件
|
||||
class ShimmerWidget extends StatefulWidget {
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BorderRadius? borderRadius;
|
||||
final Color? baseColor;
|
||||
final Color? highlightColor;
|
||||
|
||||
const ShimmerWidget({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius,
|
||||
this.baseColor,
|
||||
this.highlightColor,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShimmerWidget> createState() => _ShimmerWidgetState();
|
||||
}
|
||||
|
||||
class _ShimmerWidgetState extends State<ShimmerWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
vsync: this,
|
||||
);
|
||||
_animation = Tween<double>(
|
||||
begin: -1.0,
|
||||
end: 2.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
_animationController.repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final baseColor = widget.baseColor ?? theme.colorScheme.surfaceVariant;
|
||||
final highlightColor = widget.highlightColor ??
|
||||
theme.colorScheme.surface.withOpacity(0.8);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: widget.borderRadius ?? BorderRadius.circular(8),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
baseColor,
|
||||
highlightColor,
|
||||
baseColor,
|
||||
],
|
||||
stops: [
|
||||
_animation.value - 0.3,
|
||||
_animation.value,
|
||||
_animation.value + 0.3,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片骨架屏
|
||||
class CardShimmerWidget extends StatelessWidget {
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double? height;
|
||||
|
||||
const CardShimmerWidget({
|
||||
super.key,
|
||||
this.margin,
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: margin ?? const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const ShimmerWidget(
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ShimmerWidget(
|
||||
width: double.infinity,
|
||||
height: 16,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ShimmerWidget(
|
||||
width: 120,
|
||||
height: 12,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (height != null && height! > 100) ...[
|
||||
const SizedBox(height: 16),
|
||||
ShimmerWidget(
|
||||
width: double.infinity,
|
||||
height: 12,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ShimmerWidget(
|
||||
width: double.infinity,
|
||||
height: 12,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ShimmerWidget(
|
||||
width: 200,
|
||||
height: 12,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 按钮加载状态
|
||||
class LoadingButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
final IconData? icon;
|
||||
final Color? backgroundColor;
|
||||
final Color? foregroundColor;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
const LoadingButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.icon,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.padding,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: height ?? 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor ?? theme.colorScheme.primary,
|
||||
foregroundColor: foregroundColor ?? theme.colorScheme.onPrimary,
|
||||
padding: padding ?? const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: borderRadius ?? BorderRadius.circular(8),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
foregroundColor ?? theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新指示器
|
||||
class RefreshIndicatorWidget extends StatelessWidget {
|
||||
final Widget child;
|
||||
final Future<void> Function() onRefresh;
|
||||
final String? refreshText;
|
||||
|
||||
const RefreshIndicatorWidget({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onRefresh,
|
||||
this.refreshText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
224
client/lib/shared/widgets/network_indicator.dart
Normal file
224
client/lib/shared/widgets/network_indicator.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/network_provider.dart';
|
||||
|
||||
/// 网络状态指示器
|
||||
class NetworkIndicator extends ConsumerWidget {
|
||||
final Widget child;
|
||||
final bool showBanner;
|
||||
|
||||
const NetworkIndicator({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.showBanner = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final networkState = ref.watch(networkProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (showBanner && networkState.status == NetworkStatus.disconnected)
|
||||
_buildOfflineBanner(context),
|
||||
Expanded(child: child),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOfflineBanner(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
color: Colors.red[600],
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.wifi_off,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'网络连接已断开',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// 可以添加重试逻辑
|
||||
},
|
||||
child: const Text(
|
||||
'重试',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络状态图标
|
||||
class NetworkStatusIcon extends ConsumerWidget {
|
||||
final double size;
|
||||
final Color? color;
|
||||
|
||||
const NetworkStatusIcon({
|
||||
super.key,
|
||||
this.size = 24,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final networkState = ref.watch(networkProvider);
|
||||
|
||||
return Icon(
|
||||
_getNetworkIcon(networkState.status, networkState.type),
|
||||
size: size,
|
||||
color: color ?? _getNetworkColor(networkState.status),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getNetworkIcon(NetworkStatus status, NetworkType type) {
|
||||
if (status == NetworkStatus.disconnected) {
|
||||
return Icons.wifi_off;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case NetworkType.wifi:
|
||||
return Icons.wifi;
|
||||
case NetworkType.mobile:
|
||||
return Icons.signal_cellular_4_bar;
|
||||
case NetworkType.ethernet:
|
||||
return Icons.cable;
|
||||
case NetworkType.unknown:
|
||||
default:
|
||||
return Icons.device_unknown;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getNetworkColor(NetworkStatus status) {
|
||||
switch (status) {
|
||||
case NetworkStatus.connected:
|
||||
return Colors.green;
|
||||
case NetworkStatus.disconnected:
|
||||
return Colors.red;
|
||||
case NetworkStatus.unknown:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络状态卡片
|
||||
class NetworkStatusCard extends ConsumerWidget {
|
||||
const NetworkStatusCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final networkState = ref.watch(networkProvider);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
NetworkStatusIcon(
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_getStatusText(networkState.status),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_getTypeText(networkState.type),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'最后更新: ${_formatTime(networkState.lastChecked)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(networkProvider.notifier).refreshNetworkStatus();
|
||||
},
|
||||
child: const Text('刷新'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getStatusText(NetworkStatus status) {
|
||||
switch (status) {
|
||||
case NetworkStatus.connected:
|
||||
return '已连接';
|
||||
case NetworkStatus.disconnected:
|
||||
return '未连接';
|
||||
case NetworkStatus.unknown:
|
||||
return '未知状态';
|
||||
}
|
||||
}
|
||||
|
||||
String _getTypeText(NetworkType type) {
|
||||
switch (type) {
|
||||
case NetworkType.wifi:
|
||||
return 'Wi-Fi';
|
||||
case NetworkType.mobile:
|
||||
return '移动网络';
|
||||
case NetworkType.ethernet:
|
||||
return '以太网';
|
||||
case NetworkType.unknown:
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(time);
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
return '刚刚';
|
||||
} else if (difference.inHours < 1) {
|
||||
return '${difference.inMinutes}分钟前';
|
||||
} else if (difference.inDays < 1) {
|
||||
return '${difference.inHours}小时前';
|
||||
} else {
|
||||
return '${difference.inDays}天前';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user