init
This commit is contained in:
269
client/lib/features/reading/data/reading_static_data.dart
Normal file
269
client/lib/features/reading/data/reading_static_data.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
import '../models/reading_exercise_model.dart';
|
||||
|
||||
class ReadingStaticData {
|
||||
// 阅读分类数据
|
||||
static const List<ReadingCategory> categories = [
|
||||
ReadingCategory(
|
||||
id: 'news',
|
||||
name: '新闻资讯',
|
||||
description: '最新的国际新闻和时事报道',
|
||||
icon: 'newspaper',
|
||||
articleCount: 25,
|
||||
type: ReadingExerciseType.news,
|
||||
),
|
||||
ReadingCategory(
|
||||
id: 'story',
|
||||
name: '故事文学',
|
||||
description: '经典故事和现代文学作品',
|
||||
icon: 'book',
|
||||
articleCount: 18,
|
||||
type: ReadingExerciseType.story,
|
||||
),
|
||||
ReadingCategory(
|
||||
id: 'science',
|
||||
name: '科学探索',
|
||||
description: '科学发现和研究成果',
|
||||
icon: 'science',
|
||||
articleCount: 22,
|
||||
type: ReadingExerciseType.science,
|
||||
),
|
||||
ReadingCategory(
|
||||
id: 'business',
|
||||
name: '商业财经',
|
||||
description: '商业趋势和经济分析',
|
||||
icon: 'business',
|
||||
articleCount: 20,
|
||||
type: ReadingExerciseType.business,
|
||||
),
|
||||
ReadingCategory(
|
||||
id: 'culture',
|
||||
name: '文化艺术',
|
||||
description: '文化传统和艺术欣赏',
|
||||
icon: 'palette',
|
||||
articleCount: 15,
|
||||
type: ReadingExerciseType.culture,
|
||||
),
|
||||
ReadingCategory(
|
||||
id: 'technology',
|
||||
name: '科技前沿',
|
||||
description: '最新科技动态和创新',
|
||||
icon: 'computer',
|
||||
articleCount: 28,
|
||||
type: ReadingExerciseType.technology,
|
||||
),
|
||||
ReadingCategory(
|
||||
id: 'health',
|
||||
name: '健康生活',
|
||||
description: '健康知识和生活方式',
|
||||
icon: 'favorite',
|
||||
articleCount: 16,
|
||||
type: ReadingExerciseType.health,
|
||||
),
|
||||
ReadingCategory(
|
||||
id: 'travel',
|
||||
name: '旅游探险',
|
||||
description: '世界各地的旅游体验',
|
||||
icon: 'flight',
|
||||
articleCount: 12,
|
||||
type: ReadingExerciseType.travel,
|
||||
),
|
||||
];
|
||||
|
||||
// 阅读练习数据
|
||||
static final List<ReadingExercise> exercises = [
|
||||
// 新闻类
|
||||
ReadingExercise(
|
||||
id: 'news_1',
|
||||
title: 'Climate Change Summit Reaches Historic Agreement',
|
||||
content: '''
|
||||
The 28th Conference of the Parties (COP28) concluded yesterday with a landmark agreement that marks a significant step forward in global climate action. Representatives from 195 countries unanimously approved a comprehensive framework aimed at limiting global temperature rise to 1.5 degrees Celsius above pre-industrial levels.
|
||||
|
||||
The agreement includes ambitious targets for renewable energy adoption, with participating nations committing to triple their renewable energy capacity by 2030. Additionally, the framework establishes a \$100 billion annual fund to support developing countries in their transition to clean energy technologies.
|
||||
|
||||
Dr. Sarah Mitchell, lead climate scientist at the International Environmental Institute, described the agreement as "the most comprehensive climate action plan we've seen to date." She emphasized that the success of this initiative will depend heavily on the implementation strategies adopted by individual nations.
|
||||
|
||||
The agreement also addresses the critical issue of fossil fuel dependency, with developed nations pledging to reduce their carbon emissions by 50% within the next decade. This represents a significant acceleration from previous commitments and reflects the growing urgency of climate action.
|
||||
|
||||
However, environmental activists have expressed mixed reactions to the agreement. While acknowledging the progress made, many argue that the timeline for implementation remains too conservative given the current rate of environmental degradation.
|
||||
|
||||
The next phase will involve the development of detailed implementation plans by each participating country, with progress reviews scheduled every two years. The first comprehensive assessment is expected to take place in 2026.
|
||||
''',
|
||||
summary: '第28届联合国气候变化大会达成历史性协议,195个国家承诺将全球温升控制在1.5摄氏度以内。',
|
||||
type: ReadingExerciseType.news,
|
||||
difficulty: ReadingDifficulty.intermediate,
|
||||
wordCount: 285,
|
||||
estimatedTime: 4,
|
||||
questions: [
|
||||
ReadingQuestion(
|
||||
id: 'q1',
|
||||
question: 'How many countries participated in the COP28 agreement?',
|
||||
options: ['185', '195', '200', '210'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'The text states that "Representatives from 195 countries unanimously approved a comprehensive framework."',
|
||||
type: 'multiple_choice',
|
||||
),
|
||||
ReadingQuestion(
|
||||
id: 'q2',
|
||||
question: 'What is the target for renewable energy capacity by 2030?',
|
||||
options: ['Double', 'Triple', 'Quadruple', 'Increase by 50%'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'The agreement includes "committing to triple their renewable energy capacity by 2030."',
|
||||
type: 'multiple_choice',
|
||||
),
|
||||
ReadingQuestion(
|
||||
id: 'q3',
|
||||
question: 'The agreement establishes a fund to support developing countries.',
|
||||
options: ['True', 'False'],
|
||||
correctAnswer: 0,
|
||||
explanation: 'The text mentions "a \$100 billion annual fund to support developing countries."',
|
||||
type: 'true_false',
|
||||
),
|
||||
],
|
||||
tags: ['climate', 'environment', 'international', 'politics'],
|
||||
source: 'Global News Network',
|
||||
publishDate: DateTime(2024, 1, 15),
|
||||
imageUrl: 'assets/images/climate_summit.jpg',
|
||||
),
|
||||
|
||||
// 科技类
|
||||
ReadingExercise(
|
||||
id: 'tech_1',
|
||||
title: 'Artificial Intelligence Revolutionizes Medical Diagnosis',
|
||||
content: '''
|
||||
A groundbreaking study published in the Journal of Medical Innovation reveals that artificial intelligence systems can now diagnose certain medical conditions with accuracy rates exceeding 95%. The research, conducted over three years at leading medical institutions worldwide, demonstrates AI's potential to transform healthcare delivery.
|
||||
|
||||
The AI system, developed by MedTech Solutions, analyzes medical imaging data including X-rays, MRIs, and CT scans. By processing thousands of images and comparing them against a vast database of diagnosed cases, the system can identify patterns that might be missed by human radiologists.
|
||||
|
||||
Dr. James Chen, the study's lead researcher, explains that the AI system is particularly effective in detecting early-stage cancers and neurological disorders. "The system's ability to identify subtle abnormalities in medical images is remarkable," he notes. "It can detect changes that are barely visible to the human eye."
|
||||
|
||||
The implementation of this technology has already begun in several hospitals across North America and Europe. Initial results show a 30% reduction in diagnostic errors and a 40% decrease in the time required for diagnosis. This efficiency gain is particularly valuable in emergency situations where rapid diagnosis can be life-saving.
|
||||
|
||||
However, medical professionals emphasize that AI is intended to supplement, not replace, human expertise. The technology serves as a powerful diagnostic tool that enhances physicians' capabilities rather than substituting their clinical judgment.
|
||||
|
||||
The next phase of development focuses on expanding the AI's capabilities to include treatment recommendations and personalized medicine approaches. Researchers anticipate that within five years, AI-assisted diagnosis will become standard practice in most medical facilities worldwide.
|
||||
''',
|
||||
summary: '人工智能在医疗诊断领域取得突破,准确率超过95%,有望变革医疗服务。',
|
||||
type: ReadingExerciseType.technology,
|
||||
difficulty: ReadingDifficulty.proficient,
|
||||
wordCount: 312,
|
||||
estimatedTime: 5,
|
||||
questions: [
|
||||
ReadingQuestion(
|
||||
id: 'q1',
|
||||
question: 'What is the accuracy rate of the AI diagnostic system?',
|
||||
options: ['Over 90%', 'Over 95%', 'Over 98%', 'Over 99%'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'The text states "accuracy rates exceeding 95%."',
|
||||
type: 'multiple_choice',
|
||||
),
|
||||
ReadingQuestion(
|
||||
id: 'q2',
|
||||
question: 'How long was the research study conducted?',
|
||||
options: ['Two years', 'Three years', 'Four years', 'Five years'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'The research was "conducted over three years."',
|
||||
type: 'multiple_choice',
|
||||
),
|
||||
ReadingQuestion(
|
||||
id: 'q3',
|
||||
question: 'The AI system is designed to replace human doctors.',
|
||||
options: ['True', 'False'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'The text states that "AI is intended to supplement, not replace, human expertise."',
|
||||
type: 'true_false',
|
||||
),
|
||||
],
|
||||
tags: ['AI', 'medical', 'technology', 'healthcare'],
|
||||
source: 'Tech Today Magazine',
|
||||
publishDate: DateTime(2024, 1, 10),
|
||||
imageUrl: 'assets/images/ai_medical.jpg',
|
||||
),
|
||||
|
||||
// 故事类
|
||||
ReadingExercise(
|
||||
id: 'story_1',
|
||||
title: 'The Last Library',
|
||||
content: '''
|
||||
In the year 2087, Maya discovered something extraordinary hidden beneath the ruins of what was once New York City. As she carefully moved aside the debris, her flashlight illuminated rows upon rows of books—real books with paper pages and printed words. She had found the last library.
|
||||
|
||||
Maya had grown up in a world where all information existed in digital form, accessible through neural implants that connected directly to the Global Information Network. Physical books were considered obsolete, relics of a primitive past. Yet here she stood, surrounded by thousands of volumes that had somehow survived the Great Digital Transition of 2055.
|
||||
|
||||
She picked up a book at random—"Pride and Prejudice" by Jane Austen. As she opened it, the musty smell of old paper filled her nostrils. The sensation was unlike anything she had experienced. Each page turned with a soft whisper, and the words seemed to dance before her eyes in a way that digital text never had.
|
||||
|
||||
Hours passed as Maya explored the library. She discovered poetry that made her heart race, adventure stories that transported her to distant lands, and philosophical works that challenged her understanding of existence. For the first time in her life, she understood why her grandmother had spoken so fondly of "the old ways."
|
||||
|
||||
As the sun began to set, Maya faced a difficult decision. The authorities would surely destroy this place if they discovered it, viewing it as a dangerous reminder of inefficient past technologies. But she couldn't bear the thought of losing this treasure trove of human knowledge and creativity.
|
||||
|
||||
Maya made her choice. She would become the guardian of the last library, protecting it for future generations who might one day rediscover the magic of the written word. In a world that had forgotten the value of physical books, she would remember.
|
||||
''',
|
||||
summary: '在2087年,Maya发现了最后一座图书馆,决定成为它的守护者。',
|
||||
type: ReadingExerciseType.story,
|
||||
difficulty: ReadingDifficulty.intermediate,
|
||||
wordCount: 298,
|
||||
estimatedTime: 4,
|
||||
questions: [
|
||||
ReadingQuestion(
|
||||
id: 'q1',
|
||||
question: 'In what year does the story take place?',
|
||||
options: ['2055', '2087', '2090', '2100'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'The story begins with "In the year 2087, Maya discovered..."',
|
||||
type: 'multiple_choice',
|
||||
),
|
||||
ReadingQuestion(
|
||||
id: 'q2',
|
||||
question: 'What was the first book Maya picked up?',
|
||||
options: ['Romeo and Juliet', 'Pride and Prejudice', 'Jane Eyre', 'Sense and Sensibility'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'Maya picked up "Pride and Prejudice" by Jane Austen.',
|
||||
type: 'multiple_choice',
|
||||
),
|
||||
ReadingQuestion(
|
||||
id: 'q3',
|
||||
question: 'Maya decided to destroy the library to protect society.',
|
||||
options: ['True', 'False'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'Maya decided to become the guardian of the library, not destroy it.',
|
||||
type: 'true_false',
|
||||
),
|
||||
],
|
||||
tags: ['fiction', 'future', 'books', 'technology'],
|
||||
source: 'Future Fiction Quarterly',
|
||||
publishDate: DateTime(2024, 1, 5),
|
||||
imageUrl: 'assets/images/library_story.jpg',
|
||||
),
|
||||
];
|
||||
|
||||
// 根据类型获取练习
|
||||
static List<ReadingExercise> getExercisesByType(ReadingExerciseType type) {
|
||||
return exercises.where((exercise) => exercise.type == type).toList();
|
||||
}
|
||||
|
||||
// 根据难度获取练习
|
||||
static List<ReadingExercise> getExercisesByDifficulty(ReadingDifficulty difficulty) {
|
||||
return exercises.where((exercise) => exercise.difficulty == difficulty).toList();
|
||||
}
|
||||
|
||||
// 获取推荐练习
|
||||
static List<ReadingExercise> getRecommendedExercises() {
|
||||
return exercises.take(3).toList();
|
||||
}
|
||||
|
||||
// 根据ID获取练习
|
||||
static ReadingExercise? getExerciseById(String id) {
|
||||
try {
|
||||
return exercises.firstWhere((exercise) => exercise.id == id);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 根据类型获取分类
|
||||
static ReadingCategory? getCategoryByType(ReadingExerciseType type) {
|
||||
try {
|
||||
return categories.firstWhere((category) => category.type == type);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
138
client/lib/features/reading/models/reading_article.dart
Normal file
138
client/lib/features/reading/models/reading_article.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
class ReadingArticle {
|
||||
final String id;
|
||||
final String title;
|
||||
final String content;
|
||||
final String category;
|
||||
final String difficulty;
|
||||
final int wordCount;
|
||||
final int estimatedReadingTime; // in minutes
|
||||
final List<String> tags;
|
||||
final String source;
|
||||
final DateTime publishDate;
|
||||
final bool isCompleted;
|
||||
final double? comprehensionScore;
|
||||
final int? readingTime; // actual reading time in seconds
|
||||
|
||||
const ReadingArticle({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.category,
|
||||
required this.difficulty,
|
||||
required this.wordCount,
|
||||
required this.estimatedReadingTime,
|
||||
required this.tags,
|
||||
required this.source,
|
||||
required this.publishDate,
|
||||
this.isCompleted = false,
|
||||
this.comprehensionScore,
|
||||
this.readingTime,
|
||||
});
|
||||
|
||||
factory ReadingArticle.fromJson(Map<String, dynamic> json) {
|
||||
return ReadingArticle(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
content: json['content'] as String,
|
||||
category: json['category'] as String,
|
||||
difficulty: json['difficulty'] as String,
|
||||
wordCount: json['wordCount'] as int,
|
||||
estimatedReadingTime: json['estimatedReadingTime'] as int,
|
||||
tags: List<String>.from(json['tags'] as List),
|
||||
source: json['source'] as String,
|
||||
publishDate: DateTime.parse(json['publishDate'] as String),
|
||||
isCompleted: json['isCompleted'] as bool? ?? false,
|
||||
comprehensionScore: json['comprehensionScore'] as double?,
|
||||
readingTime: json['readingTime'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'content': content,
|
||||
'category': category,
|
||||
'difficulty': difficulty,
|
||||
'wordCount': wordCount,
|
||||
'estimatedReadingTime': estimatedReadingTime,
|
||||
'tags': tags,
|
||||
'source': source,
|
||||
'publishDate': publishDate.toIso8601String(),
|
||||
'isCompleted': isCompleted,
|
||||
'comprehensionScore': comprehensionScore,
|
||||
'readingTime': readingTime,
|
||||
};
|
||||
}
|
||||
|
||||
ReadingArticle copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? content,
|
||||
String? category,
|
||||
String? difficulty,
|
||||
int? wordCount,
|
||||
int? estimatedReadingTime,
|
||||
List<String>? tags,
|
||||
String? source,
|
||||
DateTime? publishDate,
|
||||
bool? isCompleted,
|
||||
double? comprehensionScore,
|
||||
int? readingTime,
|
||||
}) {
|
||||
return ReadingArticle(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
content: content ?? this.content,
|
||||
category: category ?? this.category,
|
||||
difficulty: difficulty ?? this.difficulty,
|
||||
wordCount: wordCount ?? this.wordCount,
|
||||
estimatedReadingTime: estimatedReadingTime ?? this.estimatedReadingTime,
|
||||
tags: tags ?? this.tags,
|
||||
source: source ?? this.source,
|
||||
publishDate: publishDate ?? this.publishDate,
|
||||
isCompleted: isCompleted ?? this.isCompleted,
|
||||
comprehensionScore: comprehensionScore ?? this.comprehensionScore,
|
||||
readingTime: readingTime ?? this.readingTime,
|
||||
);
|
||||
}
|
||||
|
||||
String get difficultyLabel {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'a1':
|
||||
case 'a2':
|
||||
return '初级';
|
||||
case 'b1':
|
||||
case 'b2':
|
||||
return '中级';
|
||||
case 'c1':
|
||||
case 'c2':
|
||||
return '高级';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
String get categoryLabel {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'cet4':
|
||||
return '四级阅读';
|
||||
case 'cet6':
|
||||
return '六级阅读';
|
||||
case 'toefl':
|
||||
return '托福阅读';
|
||||
case 'ielts':
|
||||
return '雅思阅读';
|
||||
case 'daily':
|
||||
return '日常阅读';
|
||||
case 'business':
|
||||
return '商务阅读';
|
||||
case 'academic':
|
||||
return '学术阅读';
|
||||
case 'news':
|
||||
return '新闻阅读';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
}
|
||||
125
client/lib/features/reading/models/reading_exercise_model.dart
Normal file
125
client/lib/features/reading/models/reading_exercise_model.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
/// 阅读练习类型枚举
|
||||
enum ReadingExerciseType {
|
||||
news, // 新闻
|
||||
story, // 故事
|
||||
science, // 科学
|
||||
business, // 商务
|
||||
culture, // 文化
|
||||
technology, // 科技
|
||||
health, // 健康
|
||||
travel, // 旅游
|
||||
}
|
||||
|
||||
/// 阅读难度枚举
|
||||
enum ReadingDifficulty {
|
||||
elementary, // 初级 A1-A2
|
||||
intermediate, // 中级 B1
|
||||
upperIntermediate, // 中高级 B2
|
||||
advanced, // 高级 C1
|
||||
proficient, // 精通 C2
|
||||
}
|
||||
|
||||
/// 阅读练习分类
|
||||
class ReadingCategory {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final String icon;
|
||||
final int articleCount;
|
||||
final ReadingExerciseType type;
|
||||
|
||||
const ReadingCategory({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
required this.articleCount,
|
||||
required this.type,
|
||||
});
|
||||
}
|
||||
|
||||
/// 阅读练习
|
||||
class ReadingExercise {
|
||||
final String id;
|
||||
final String title;
|
||||
final String content;
|
||||
final String summary;
|
||||
final ReadingExerciseType type;
|
||||
final ReadingDifficulty difficulty;
|
||||
final int wordCount;
|
||||
final int estimatedTime; // 预估阅读时间(分钟)
|
||||
final List<ReadingQuestion> questions;
|
||||
final List<String> tags;
|
||||
final String source;
|
||||
final DateTime publishDate;
|
||||
final String imageUrl;
|
||||
|
||||
const ReadingExercise({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.summary,
|
||||
required this.type,
|
||||
required this.difficulty,
|
||||
required this.wordCount,
|
||||
required this.estimatedTime,
|
||||
required this.questions,
|
||||
required this.tags,
|
||||
required this.source,
|
||||
required this.publishDate,
|
||||
required this.imageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/// 阅读问题
|
||||
class ReadingQuestion {
|
||||
final String id;
|
||||
final String question;
|
||||
final List<String> options;
|
||||
final int correctAnswer; // 正确答案的索引
|
||||
final String explanation;
|
||||
final String type; // multiple_choice, true_false, fill_blank
|
||||
|
||||
const ReadingQuestion({
|
||||
required this.id,
|
||||
required this.question,
|
||||
required this.options,
|
||||
required this.correctAnswer,
|
||||
required this.explanation,
|
||||
required this.type,
|
||||
});
|
||||
}
|
||||
|
||||
/// 阅读结果
|
||||
class ReadingResult {
|
||||
final String exerciseId;
|
||||
final int correctAnswers;
|
||||
final int totalQuestions;
|
||||
final int readingTime; // 实际阅读时间(秒)
|
||||
final DateTime completedAt;
|
||||
final List<UserAnswer> userAnswers;
|
||||
|
||||
const ReadingResult({
|
||||
required this.exerciseId,
|
||||
required this.correctAnswers,
|
||||
required this.totalQuestions,
|
||||
required this.readingTime,
|
||||
required this.completedAt,
|
||||
required this.userAnswers,
|
||||
});
|
||||
|
||||
double get accuracy => correctAnswers / totalQuestions;
|
||||
}
|
||||
|
||||
/// 用户答案
|
||||
class UserAnswer {
|
||||
final String questionId;
|
||||
final int selectedAnswer;
|
||||
final bool isCorrect;
|
||||
|
||||
const UserAnswer({
|
||||
required this.questionId,
|
||||
required this.selectedAnswer,
|
||||
required this.isCorrect,
|
||||
});
|
||||
}
|
||||
241
client/lib/features/reading/models/reading_question.dart
Normal file
241
client/lib/features/reading/models/reading_question.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
enum QuestionType {
|
||||
multipleChoice,
|
||||
trueFalse,
|
||||
fillInBlank,
|
||||
shortAnswer,
|
||||
}
|
||||
|
||||
class ReadingQuestion {
|
||||
final String id;
|
||||
final String articleId;
|
||||
final QuestionType type;
|
||||
final String question;
|
||||
final List<String> options; // For multiple choice questions
|
||||
final String correctAnswer;
|
||||
final String explanation;
|
||||
final int order;
|
||||
final String? userAnswer;
|
||||
final bool? isCorrect;
|
||||
|
||||
const ReadingQuestion({
|
||||
required this.id,
|
||||
required this.articleId,
|
||||
required this.type,
|
||||
required this.question,
|
||||
required this.options,
|
||||
required this.correctAnswer,
|
||||
required this.explanation,
|
||||
required this.order,
|
||||
this.userAnswer,
|
||||
this.isCorrect,
|
||||
});
|
||||
|
||||
factory ReadingQuestion.fromJson(Map<String, dynamic> json) {
|
||||
return ReadingQuestion(
|
||||
id: json['id'] as String,
|
||||
articleId: json['articleId'] as String,
|
||||
type: QuestionType.values.firstWhere(
|
||||
(e) => e.toString().split('.').last == json['type'],
|
||||
orElse: () => QuestionType.multipleChoice,
|
||||
),
|
||||
question: json['question'] as String,
|
||||
options: List<String>.from(json['options'] as List? ?? []),
|
||||
correctAnswer: json['correctAnswer'] as String,
|
||||
explanation: json['explanation'] as String,
|
||||
order: json['order'] as int,
|
||||
userAnswer: json['userAnswer'] as String?,
|
||||
isCorrect: json['isCorrect'] as bool?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'articleId': articleId,
|
||||
'type': type.toString().split('.').last,
|
||||
'question': question,
|
||||
'options': options,
|
||||
'correctAnswer': correctAnswer,
|
||||
'explanation': explanation,
|
||||
'order': order,
|
||||
'userAnswer': userAnswer,
|
||||
'isCorrect': isCorrect,
|
||||
};
|
||||
}
|
||||
|
||||
ReadingQuestion copyWith({
|
||||
String? id,
|
||||
String? articleId,
|
||||
QuestionType? type,
|
||||
String? question,
|
||||
List<String>? options,
|
||||
String? correctAnswer,
|
||||
String? explanation,
|
||||
int? order,
|
||||
String? userAnswer,
|
||||
bool? isCorrect,
|
||||
}) {
|
||||
return ReadingQuestion(
|
||||
id: id ?? this.id,
|
||||
articleId: articleId ?? this.articleId,
|
||||
type: type ?? this.type,
|
||||
question: question ?? this.question,
|
||||
options: options ?? this.options,
|
||||
correctAnswer: correctAnswer ?? this.correctAnswer,
|
||||
explanation: explanation ?? this.explanation,
|
||||
order: order ?? this.order,
|
||||
userAnswer: userAnswer ?? this.userAnswer,
|
||||
isCorrect: isCorrect ?? this.isCorrect,
|
||||
);
|
||||
}
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case QuestionType.multipleChoice:
|
||||
return '选择题';
|
||||
case QuestionType.trueFalse:
|
||||
return '判断题';
|
||||
case QuestionType.fillInBlank:
|
||||
return '填空题';
|
||||
case QuestionType.shortAnswer:
|
||||
return '简答题';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReadingExercise {
|
||||
final String id;
|
||||
final String articleId;
|
||||
final List<ReadingQuestion> questions;
|
||||
final DateTime? startTime;
|
||||
final DateTime? endTime;
|
||||
final double? score;
|
||||
final bool isCompleted;
|
||||
|
||||
const ReadingExercise({
|
||||
required this.id,
|
||||
required this.articleId,
|
||||
required this.questions,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.score,
|
||||
this.isCompleted = false,
|
||||
});
|
||||
|
||||
factory ReadingExercise.fromJson(Map<String, dynamic> json) {
|
||||
return ReadingExercise(
|
||||
id: json['id'] as String,
|
||||
articleId: json['articleId'] as String,
|
||||
questions: (json['questions'] as List)
|
||||
.map((q) => ReadingQuestion.fromJson(q as Map<String, dynamic>))
|
||||
.toList(),
|
||||
startTime: json['startTime'] != null
|
||||
? DateTime.parse(json['startTime'] as String)
|
||||
: null,
|
||||
endTime: json['endTime'] != null
|
||||
? DateTime.parse(json['endTime'] as String)
|
||||
: null,
|
||||
score: json['score'] as double?,
|
||||
isCompleted: json['isCompleted'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'articleId': articleId,
|
||||
'questions': questions.map((q) => q.toJson()).toList(),
|
||||
'startTime': startTime?.toIso8601String(),
|
||||
'endTime': endTime?.toIso8601String(),
|
||||
'score': score,
|
||||
'isCompleted': isCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
ReadingExercise copyWith({
|
||||
String? id,
|
||||
String? articleId,
|
||||
List<ReadingQuestion>? questions,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
double? score,
|
||||
bool? isCompleted,
|
||||
}) {
|
||||
return ReadingExercise(
|
||||
id: id ?? this.id,
|
||||
articleId: articleId ?? this.articleId,
|
||||
questions: questions ?? this.questions,
|
||||
startTime: startTime ?? this.startTime,
|
||||
endTime: endTime ?? this.endTime,
|
||||
score: score ?? this.score,
|
||||
isCompleted: isCompleted ?? this.isCompleted,
|
||||
);
|
||||
}
|
||||
|
||||
int get totalQuestions => questions.length;
|
||||
|
||||
int get answeredQuestions {
|
||||
return questions.where((q) => q.userAnswer != null).length;
|
||||
}
|
||||
|
||||
int get correctAnswers {
|
||||
return questions.where((q) => q.isCorrect == true).length;
|
||||
}
|
||||
|
||||
double get progressPercentage {
|
||||
if (totalQuestions == 0) return 0.0;
|
||||
return (answeredQuestions / totalQuestions) * 100;
|
||||
}
|
||||
|
||||
Duration? get duration {
|
||||
if (startTime != null && endTime != null) {
|
||||
return endTime!.difference(startTime!);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读练习结果
|
||||
class ReadingExerciseResult {
|
||||
final double score;
|
||||
final int correctCount;
|
||||
final int totalCount;
|
||||
final Duration timeSpent; // 用时
|
||||
final double accuracy;
|
||||
|
||||
const ReadingExerciseResult({
|
||||
required this.score,
|
||||
required this.correctCount,
|
||||
required this.totalCount,
|
||||
required this.timeSpent,
|
||||
required this.accuracy,
|
||||
});
|
||||
|
||||
/// 错误题数
|
||||
int get wrongCount => totalCount - correctCount;
|
||||
|
||||
/// 总题数(别名)
|
||||
int get totalQuestions => totalCount;
|
||||
|
||||
factory ReadingExerciseResult.fromJson(Map<String, dynamic> json) {
|
||||
return ReadingExerciseResult(
|
||||
score: (json['score'] as num).toDouble(),
|
||||
correctCount: json['correctCount'] as int,
|
||||
totalCount: json['totalCount'] as int,
|
||||
timeSpent: Duration(seconds: json['timeSpent'] as int),
|
||||
accuracy: (json['accuracy'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'score': score,
|
||||
'correctCount': correctCount,
|
||||
'totalCount': totalCount,
|
||||
'timeSpent': timeSpent.inSeconds,
|
||||
'accuracy': accuracy,
|
||||
};
|
||||
}
|
||||
|
||||
bool get isPassed => score >= 60.0;
|
||||
}
|
||||
280
client/lib/features/reading/models/reading_stats.dart
Normal file
280
client/lib/features/reading/models/reading_stats.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
class ReadingStats {
|
||||
final int totalArticlesRead;
|
||||
final int practicesDone;
|
||||
final double averageScore;
|
||||
final int totalReadingTime; // in minutes
|
||||
final double averageReadingSpeed; // words per minute
|
||||
final double comprehensionAccuracy; // percentage
|
||||
final int vocabularyMastered;
|
||||
final int consecutiveDays;
|
||||
final Map<String, int> categoryStats; // category -> articles read
|
||||
final Map<String, double> difficultyStats; // difficulty -> average score
|
||||
final List<DailyReadingRecord> dailyRecords;
|
||||
|
||||
const ReadingStats({
|
||||
required this.totalArticlesRead,
|
||||
required this.practicesDone,
|
||||
required this.averageScore,
|
||||
required this.totalReadingTime,
|
||||
required this.averageReadingSpeed,
|
||||
required this.comprehensionAccuracy,
|
||||
required this.vocabularyMastered,
|
||||
required this.consecutiveDays,
|
||||
required this.categoryStats,
|
||||
required this.difficultyStats,
|
||||
required this.dailyRecords,
|
||||
});
|
||||
|
||||
factory ReadingStats.fromJson(Map<String, dynamic> json) {
|
||||
final totalArticlesRead = json['totalArticlesRead'];
|
||||
final practicesDone = json['practicesDone'];
|
||||
final averageScore = json['averageScore'];
|
||||
final totalReadingTime = json['totalReadingTime'];
|
||||
final averageReadingSpeed = json['averageReadingSpeed'];
|
||||
final comprehensionAccuracy = json['comprehensionAccuracy'];
|
||||
final vocabularyMastered = json['vocabularyMastered'];
|
||||
final consecutiveDays = json['consecutiveDays'];
|
||||
|
||||
final rawCategoryStats = json['categoryStats'];
|
||||
final Map<String, int> categoryStats = {};
|
||||
if (rawCategoryStats is Map) {
|
||||
rawCategoryStats.forEach((key, value) {
|
||||
final k = key is String ? key : key.toString();
|
||||
final v = value is num ? value.toInt() : 0;
|
||||
categoryStats[k] = v;
|
||||
});
|
||||
}
|
||||
|
||||
final rawDifficultyStats = json['difficultyStats'];
|
||||
final Map<String, double> difficultyStats = {};
|
||||
if (rawDifficultyStats is Map) {
|
||||
rawDifficultyStats.forEach((key, value) {
|
||||
final k = key is String ? key : key.toString();
|
||||
final v = value is num ? value.toDouble() : 0.0;
|
||||
difficultyStats[k] = v;
|
||||
});
|
||||
}
|
||||
|
||||
final rawDailyRecords = json['dailyRecords'];
|
||||
final List<DailyReadingRecord> dailyRecords = [];
|
||||
if (rawDailyRecords is List) {
|
||||
for (final record in rawDailyRecords) {
|
||||
if (record is Map<String, dynamic>) {
|
||||
dailyRecords.add(DailyReadingRecord.fromJson(record));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ReadingStats(
|
||||
totalArticlesRead: totalArticlesRead is num ? totalArticlesRead.toInt() : 0,
|
||||
practicesDone: practicesDone is num ? practicesDone.toInt() : 0,
|
||||
averageScore: averageScore is num ? averageScore.toDouble() : 0.0,
|
||||
totalReadingTime: totalReadingTime is num ? totalReadingTime.toInt() : 0,
|
||||
averageReadingSpeed: averageReadingSpeed is num ? averageReadingSpeed.toDouble() : 0.0,
|
||||
comprehensionAccuracy: comprehensionAccuracy is num ? comprehensionAccuracy.toDouble() : 0.0,
|
||||
vocabularyMastered: vocabularyMastered is num ? vocabularyMastered.toInt() : 0,
|
||||
consecutiveDays: consecutiveDays is num ? consecutiveDays.toInt() : 0,
|
||||
categoryStats: categoryStats,
|
||||
difficultyStats: difficultyStats,
|
||||
dailyRecords: dailyRecords,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'totalArticlesRead': totalArticlesRead,
|
||||
'practicesDone': practicesDone,
|
||||
'averageScore': averageScore,
|
||||
'totalReadingTime': totalReadingTime,
|
||||
'averageReadingSpeed': averageReadingSpeed,
|
||||
'comprehensionAccuracy': comprehensionAccuracy,
|
||||
'vocabularyMastered': vocabularyMastered,
|
||||
'consecutiveDays': consecutiveDays,
|
||||
'categoryStats': categoryStats,
|
||||
'difficultyStats': difficultyStats,
|
||||
'dailyRecords': dailyRecords.map((record) => record.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
ReadingStats copyWith({
|
||||
int? totalArticlesRead,
|
||||
int? practicesDone,
|
||||
double? averageScore,
|
||||
int? totalReadingTime,
|
||||
double? averageReadingSpeed,
|
||||
double? comprehensionAccuracy,
|
||||
int? vocabularyMastered,
|
||||
int? consecutiveDays,
|
||||
Map<String, int>? categoryStats,
|
||||
Map<String, double>? difficultyStats,
|
||||
List<DailyReadingRecord>? dailyRecords,
|
||||
}) {
|
||||
return ReadingStats(
|
||||
totalArticlesRead: totalArticlesRead ?? this.totalArticlesRead,
|
||||
practicesDone: practicesDone ?? this.practicesDone,
|
||||
averageScore: averageScore ?? this.averageScore,
|
||||
totalReadingTime: totalReadingTime ?? this.totalReadingTime,
|
||||
averageReadingSpeed: averageReadingSpeed ?? this.averageReadingSpeed,
|
||||
comprehensionAccuracy: comprehensionAccuracy ?? this.comprehensionAccuracy,
|
||||
vocabularyMastered: vocabularyMastered ?? this.vocabularyMastered,
|
||||
consecutiveDays: consecutiveDays ?? this.consecutiveDays,
|
||||
categoryStats: categoryStats ?? this.categoryStats,
|
||||
difficultyStats: difficultyStats ?? this.difficultyStats,
|
||||
dailyRecords: dailyRecords ?? this.dailyRecords,
|
||||
);
|
||||
}
|
||||
|
||||
String get readingTimeFormatted {
|
||||
final hours = totalReadingTime ~/ 60;
|
||||
final minutes = totalReadingTime % 60;
|
||||
if (hours > 0) {
|
||||
return '${hours}小时${minutes}分钟';
|
||||
}
|
||||
return '${minutes}分钟';
|
||||
}
|
||||
|
||||
String get averageScoreFormatted {
|
||||
return '${averageScore.toStringAsFixed(1)}分';
|
||||
}
|
||||
|
||||
String get comprehensionAccuracyFormatted {
|
||||
return '${comprehensionAccuracy.toStringAsFixed(1)}%';
|
||||
}
|
||||
|
||||
String get averageReadingSpeedFormatted {
|
||||
return '${averageReadingSpeed.toStringAsFixed(0)}词/分钟';
|
||||
}
|
||||
}
|
||||
|
||||
class DailyReadingRecord {
|
||||
final DateTime date;
|
||||
final int articlesRead;
|
||||
final int practicesDone;
|
||||
final int readingTime; // in minutes
|
||||
final double averageScore;
|
||||
final int vocabularyLearned;
|
||||
|
||||
const DailyReadingRecord({
|
||||
required this.date,
|
||||
required this.articlesRead,
|
||||
required this.practicesDone,
|
||||
required this.readingTime,
|
||||
required this.averageScore,
|
||||
required this.vocabularyLearned,
|
||||
});
|
||||
|
||||
factory DailyReadingRecord.fromJson(Map<String, dynamic> json) {
|
||||
final dateRaw = json['date'];
|
||||
final articlesRead = json['articlesRead'];
|
||||
final practicesDone = json['practicesDone'];
|
||||
final readingTime = json['readingTime'];
|
||||
final averageScore = json['averageScore'];
|
||||
final vocabularyLearned = json['vocabularyLearned'];
|
||||
|
||||
return DailyReadingRecord(
|
||||
date: dateRaw is String ? DateTime.parse(dateRaw) : DateTime.fromMillisecondsSinceEpoch(0),
|
||||
articlesRead: articlesRead is num ? articlesRead.toInt() : 0,
|
||||
practicesDone: practicesDone is num ? practicesDone.toInt() : 0,
|
||||
readingTime: readingTime is num ? readingTime.toInt() : 0,
|
||||
averageScore: averageScore is num ? averageScore.toDouble() : 0.0,
|
||||
vocabularyLearned: vocabularyLearned is num ? vocabularyLearned.toInt() : 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'date': date.toIso8601String(),
|
||||
'articlesRead': articlesRead,
|
||||
'practicesDone': practicesDone,
|
||||
'readingTime': readingTime,
|
||||
'averageScore': averageScore,
|
||||
'vocabularyLearned': vocabularyLearned,
|
||||
};
|
||||
}
|
||||
|
||||
DailyReadingRecord copyWith({
|
||||
DateTime? date,
|
||||
int? articlesRead,
|
||||
int? practicesDone,
|
||||
int? readingTime,
|
||||
double? averageScore,
|
||||
int? vocabularyLearned,
|
||||
}) {
|
||||
return DailyReadingRecord(
|
||||
date: date ?? this.date,
|
||||
articlesRead: articlesRead ?? this.articlesRead,
|
||||
practicesDone: practicesDone ?? this.practicesDone,
|
||||
readingTime: readingTime ?? this.readingTime,
|
||||
averageScore: averageScore ?? this.averageScore,
|
||||
vocabularyLearned: vocabularyLearned ?? this.vocabularyLearned,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReadingProgress {
|
||||
final String category;
|
||||
final int totalArticles;
|
||||
final int completedArticles;
|
||||
final double averageScore;
|
||||
final String difficulty;
|
||||
|
||||
const ReadingProgress({
|
||||
required this.category,
|
||||
required this.totalArticles,
|
||||
required this.completedArticles,
|
||||
required this.averageScore,
|
||||
required this.difficulty,
|
||||
});
|
||||
|
||||
factory ReadingProgress.fromJson(Map<String, dynamic> json) {
|
||||
return ReadingProgress(
|
||||
category: json['category'] as String,
|
||||
totalArticles: json['totalArticles'] as int,
|
||||
completedArticles: json['completedArticles'] as int,
|
||||
averageScore: (json['averageScore'] as num).toDouble(),
|
||||
difficulty: json['difficulty'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'category': category,
|
||||
'totalArticles': totalArticles,
|
||||
'completedArticles': completedArticles,
|
||||
'averageScore': averageScore,
|
||||
'difficulty': difficulty,
|
||||
};
|
||||
}
|
||||
|
||||
double get progressPercentage {
|
||||
if (totalArticles == 0) return 0.0;
|
||||
return (completedArticles / totalArticles) * 100;
|
||||
}
|
||||
|
||||
String get progressText {
|
||||
return '$completedArticles/$totalArticles';
|
||||
}
|
||||
|
||||
String get categoryLabel {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'cet4':
|
||||
return '四级阅读';
|
||||
case 'cet6':
|
||||
return '六级阅读';
|
||||
case 'toefl':
|
||||
return '托福阅读';
|
||||
case 'ielts':
|
||||
return '雅思阅读';
|
||||
case 'daily':
|
||||
return '日常阅读';
|
||||
case 'business':
|
||||
return '商务阅读';
|
||||
case 'academic':
|
||||
return '学术阅读';
|
||||
case 'news':
|
||||
return '新闻阅读';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
}
|
||||
189
client/lib/features/reading/providers/reading_provider.dart
Normal file
189
client/lib/features/reading/providers/reading_provider.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/reading_exercise_model.dart';
|
||||
import '../services/reading_service.dart';
|
||||
|
||||
/// 阅读材料列表状态
|
||||
class ReadingMaterialsState {
|
||||
final List<ReadingExercise> materials;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
ReadingMaterialsState({
|
||||
this.materials = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
ReadingMaterialsState copyWith({
|
||||
List<ReadingExercise>? materials,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return ReadingMaterialsState(
|
||||
materials: materials ?? this.materials,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读材料列表 Notifier
|
||||
class ReadingMaterialsNotifier extends StateNotifier<ReadingMaterialsState> {
|
||||
final ReadingService _readingService;
|
||||
|
||||
ReadingMaterialsNotifier(this._readingService) : super(ReadingMaterialsState());
|
||||
|
||||
/// 加载阅读材料列表
|
||||
Future<void> loadMaterials({
|
||||
String? category,
|
||||
String? difficulty,
|
||||
int page = 1,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final materials = await _readingService.getArticles(
|
||||
category: category,
|
||||
difficulty: difficulty,
|
||||
page: page,
|
||||
);
|
||||
|
||||
// 将后端数据转换为前端模型
|
||||
final exercises = materials.map<ReadingExercise>((article) {
|
||||
return ReadingExercise(
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
content: article.content,
|
||||
summary: '', // 后端ReadingArticle没有summary字段
|
||||
type: _mapCategoryToType(article.category),
|
||||
difficulty: _mapDifficultyLevel(article.difficulty),
|
||||
wordCount: article.wordCount,
|
||||
estimatedTime: article.estimatedReadingTime,
|
||||
questions: [], // 题目需要单独获取
|
||||
tags: article.tags, // 已经是List<String>
|
||||
source: article.source,
|
||||
publishDate: article.publishDate,
|
||||
imageUrl: '', // 后端暂无图片字段
|
||||
);
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(materials: exercises, isLoading: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取推荐材料
|
||||
Future<void> loadRecommendedMaterials() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final materials = await _readingService.getRecommendedArticles(limit: 3);
|
||||
|
||||
final exercises = materials.map<ReadingExercise>((article) {
|
||||
return ReadingExercise(
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
content: article.content,
|
||||
summary: '', // 后端ReadingArticle没有summary字段
|
||||
type: _mapCategoryToType(article.category),
|
||||
difficulty: _mapDifficultyLevel(article.difficulty),
|
||||
wordCount: article.wordCount,
|
||||
estimatedTime: article.estimatedReadingTime,
|
||||
questions: [],
|
||||
tags: article.tags, // 已经是List<String>
|
||||
source: article.source,
|
||||
publishDate: article.publishDate,
|
||||
imageUrl: '', // 后端暂无图片字段
|
||||
);
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(materials: exercises, isLoading: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReadingExerciseType _mapCategoryToType(String category) {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'news':
|
||||
return ReadingExerciseType.news;
|
||||
case 'story':
|
||||
return ReadingExerciseType.story;
|
||||
case 'science':
|
||||
return ReadingExerciseType.science;
|
||||
case 'business':
|
||||
return ReadingExerciseType.business;
|
||||
case 'technology':
|
||||
return ReadingExerciseType.technology;
|
||||
default:
|
||||
return ReadingExerciseType.news;
|
||||
}
|
||||
}
|
||||
|
||||
ReadingDifficulty _mapDifficultyLevel(String difficulty) {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'beginner':
|
||||
case 'elementary':
|
||||
return ReadingDifficulty.elementary;
|
||||
case 'intermediate':
|
||||
return ReadingDifficulty.intermediate;
|
||||
case 'upper-intermediate':
|
||||
case 'upperintermediate':
|
||||
return ReadingDifficulty.upperIntermediate;
|
||||
case 'advanced':
|
||||
return ReadingDifficulty.advanced;
|
||||
case 'proficient':
|
||||
return ReadingDifficulty.proficient;
|
||||
default:
|
||||
return ReadingDifficulty.intermediate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读服务 Provider
|
||||
final readingServiceProvider = Provider<ReadingService>((ref) {
|
||||
return ReadingService();
|
||||
});
|
||||
|
||||
/// 阅读材料列表 Provider
|
||||
final readingMaterialsProvider = StateNotifierProvider<ReadingMaterialsNotifier, ReadingMaterialsState>((ref) {
|
||||
final service = ref.watch(readingServiceProvider);
|
||||
return ReadingMaterialsNotifier(service);
|
||||
});
|
||||
|
||||
/// 推荐阅读材料 Provider
|
||||
final recommendedReadingProvider = FutureProvider<List<ReadingExercise>>((ref) async {
|
||||
final service = ref.watch(readingServiceProvider);
|
||||
|
||||
try {
|
||||
final materials = await service.getRecommendedArticles(limit: 3);
|
||||
|
||||
return materials.map<ReadingExercise>((article) {
|
||||
return ReadingExercise(
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
content: article.content,
|
||||
summary: '', // 后端ReadingArticle没有summary字段
|
||||
type: ReadingExerciseType.news,
|
||||
difficulty: ReadingDifficulty.intermediate,
|
||||
wordCount: article.wordCount,
|
||||
estimatedTime: article.estimatedReadingTime,
|
||||
questions: [],
|
||||
tags: article.tags, // 已经是List<String>
|
||||
source: article.source,
|
||||
publishDate: article.publishDate,
|
||||
imageUrl: '', // 后端暂无图片字段
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
// 发生错误时返回空列表
|
||||
return [];
|
||||
}
|
||||
});
|
||||
426
client/lib/features/reading/screens/reading_article_screen.dart
Normal file
426
client/lib/features/reading/screens/reading_article_screen.dart
Normal file
@@ -0,0 +1,426 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/reading_provider.dart';
|
||||
import '../models/reading_article.dart';
|
||||
import '../widgets/reading_content_widget.dart';
|
||||
import '../widgets/reading_toolbar.dart';
|
||||
import 'reading_exercise_screen.dart';
|
||||
|
||||
/// 阅读文章详情页面
|
||||
class ReadingArticleScreen extends StatefulWidget {
|
||||
final String articleId;
|
||||
|
||||
const ReadingArticleScreen({
|
||||
super.key,
|
||||
required this.articleId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingArticleScreen> createState() => _ReadingArticleScreenState();
|
||||
}
|
||||
|
||||
class _ReadingArticleScreenState extends State<ReadingArticleScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isReading = false;
|
||||
DateTime? _startTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadArticle();
|
||||
_startReading();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_endReading();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadArticle() {
|
||||
final provider = context.read<ReadingProvider>();
|
||||
provider.loadArticle(widget.articleId);
|
||||
}
|
||||
|
||||
void _startReading() {
|
||||
_startTime = DateTime.now();
|
||||
_isReading = true;
|
||||
}
|
||||
|
||||
void _endReading() {
|
||||
if (_isReading && _startTime != null) {
|
||||
final duration = DateTime.now().difference(_startTime!);
|
||||
final provider = context.read<ReadingProvider>();
|
||||
provider.recordReadingProgress(
|
||||
articleId: widget.articleId,
|
||||
readingTime: duration.inSeconds,
|
||||
completed: true,
|
||||
);
|
||||
_isReading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'阅读文章',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
elevation: 0,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
actions: [
|
||||
Consumer<ReadingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final article = provider.currentArticle;
|
||||
if (article == null) return const SizedBox.shrink();
|
||||
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
Icons.favorite,
|
||||
color: provider.favoriteArticles.any((a) => a.id == article.id)
|
||||
? Colors.red
|
||||
: Colors.white,
|
||||
),
|
||||
onPressed: () => _toggleFavorite(article),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share, color: Colors.white),
|
||||
onPressed: _shareArticle,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<ReadingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (provider.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
provider.error!,
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadArticle,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final article = provider.currentArticle;
|
||||
if (article == null) {
|
||||
return const Center(
|
||||
child: Text('文章不存在'),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 文章信息头部
|
||||
_buildArticleHeader(article),
|
||||
|
||||
// 阅读工具栏
|
||||
const ReadingToolbar(),
|
||||
|
||||
// 文章内容
|
||||
Expanded(
|
||||
child: ReadingContentWidget(
|
||||
article: article,
|
||||
scrollController: _scrollController,
|
||||
),
|
||||
),
|
||||
|
||||
// 底部操作栏
|
||||
_buildBottomActions(article),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建文章信息头部
|
||||
Widget _buildArticleHeader(ReadingArticle article) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
article.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 文章信息
|
||||
Row(
|
||||
children: [
|
||||
// 分类标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
article.categoryLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF2196F3),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 难度标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDifficultyColor(article.difficulty).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
article.difficultyLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getDifficultyColor(article.difficulty),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// 字数和阅读时间
|
||||
Text(
|
||||
'${article.wordCount}词 · ${article.readingTime}分钟',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 来源和发布时间
|
||||
if (article.source != null || article.publishDate != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (article.source != null) ...[
|
||||
Icon(
|
||||
Icons.source,
|
||||
size: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
article.source!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (article.source != null && article.publishDate != null)
|
||||
Text(
|
||||
' · ',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
if (article.publishDate != null) ...[
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDate(article.publishDate!),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部操作栏
|
||||
Widget _buildBottomActions(ReadingArticle article) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 开始练习按钮
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _startExercise(article),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'开始练习',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 重新阅读按钮
|
||||
OutlinedButton(
|
||||
onPressed: () => _scrollToTop(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF2196F3),
|
||||
side: const BorderSide(color: Color(0xFF2196F3)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('重新阅读'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取难度颜色
|
||||
Color _getDifficultyColor(String difficulty) {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'a1':
|
||||
case 'a2':
|
||||
return Colors.green;
|
||||
case 'b1':
|
||||
case 'b2':
|
||||
return Colors.orange;
|
||||
case 'c1':
|
||||
case 'c2':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化日期
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 切换收藏状态
|
||||
void _toggleFavorite(ReadingArticle article) {
|
||||
final provider = context.read<ReadingProvider>();
|
||||
final isFavorite = provider.favoriteArticles.any((a) => a.id == article.id);
|
||||
|
||||
if (isFavorite) {
|
||||
provider.unfavoriteArticle(article.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已取消收藏')),
|
||||
);
|
||||
} else {
|
||||
provider.favoriteArticle(article.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已添加到收藏')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分享文章
|
||||
void _shareArticle() {
|
||||
final article = context.read<ReadingProvider>().currentArticle;
|
||||
if (article != null) {
|
||||
// TODO: 实现分享功能
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('分享功能开发中...')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始练习
|
||||
void _startExercise(ReadingArticle article) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ReadingExerciseScreen(articleId: article.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 滚动到顶部
|
||||
void _scrollToTop() {
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
499
client/lib/features/reading/screens/reading_category_screen.dart
Normal file
499
client/lib/features/reading/screens/reading_category_screen.dart
Normal file
@@ -0,0 +1,499 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/reading_exercise_model.dart';
|
||||
import '../providers/reading_provider.dart';
|
||||
import 'reading_exercise_screen.dart';
|
||||
|
||||
/// 阅读分类页面
|
||||
class ReadingCategoryScreen extends ConsumerStatefulWidget {
|
||||
final ReadingExerciseType exerciseType;
|
||||
|
||||
const ReadingCategoryScreen({
|
||||
super.key,
|
||||
required this.exerciseType,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ReadingCategoryScreen> createState() => _ReadingCategoryScreenState();
|
||||
}
|
||||
|
||||
class _ReadingCategoryScreenState extends ConsumerState<ReadingCategoryScreen> {
|
||||
ReadingDifficulty? selectedDifficulty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadRemote();
|
||||
});
|
||||
}
|
||||
|
||||
void _loadRemote() {
|
||||
final cat = _typeToCategory(widget.exerciseType);
|
||||
final level = selectedDifficulty != null ? _difficultyToLevel(selectedDifficulty!) : null;
|
||||
ref.read(readingMaterialsProvider.notifier).loadMaterials(
|
||||
category: cat,
|
||||
difficulty: level,
|
||||
page: 1,
|
||||
);
|
||||
}
|
||||
|
||||
void _filterExercises() {
|
||||
_loadRemote();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(
|
||||
_categoryLabel(widget.exerciseType),
|
||||
style: const TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
_loadRemote();
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
_buildFilterSection(),
|
||||
Expanded(
|
||||
child: _buildExercisesList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterSection() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'根据难度筛选文章',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'难度筛选',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 响应式难度筛选布局
|
||||
constraints.maxWidth > 600
|
||||
? Row(
|
||||
children: [
|
||||
_buildDifficultyChip('全部', null),
|
||||
const SizedBox(width: 8),
|
||||
...ReadingDifficulty.values.map((difficulty) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildDifficultyChip(_getDifficultyName(difficulty), difficulty),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildDifficultyChip('全部', null),
|
||||
...ReadingDifficulty.values.map((difficulty) =>
|
||||
_buildDifficultyChip(_getDifficultyName(difficulty), difficulty)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDifficultyChip(String label, ReadingDifficulty? difficulty) {
|
||||
final isSelected = selectedDifficulty == difficulty;
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
selectedDifficulty = selected ? difficulty : null;
|
||||
_loadRemote();
|
||||
});
|
||||
},
|
||||
selectedColor: const Color(0xFF2196F3).withOpacity(0.2),
|
||||
checkmarkColor: const Color(0xFF2196F3),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExercisesList() {
|
||||
final state = ref.watch(readingMaterialsProvider);
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 64, color: Colors.redAccent),
|
||||
const SizedBox(height: 16),
|
||||
Text('加载失败: ${state.error}', style: const TextStyle(color: Colors.redAccent)),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _loadRemote,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final exercises = state.materials;
|
||||
if (exercises.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.book_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无相关练习',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth > 800) {
|
||||
return _buildGridLayout(exercises);
|
||||
} else {
|
||||
return _buildListLayout(exercises);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListLayout(List<ReadingExercise> exercises) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: exercises.length,
|
||||
itemBuilder: (context, index) {
|
||||
final exercise = exercises[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildExerciseCard(exercise),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridLayout(List<ReadingExercise> exercises) {
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _getCrossAxisCount(MediaQuery.of(context).size.width),
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemCount: exercises.length,
|
||||
itemBuilder: (context, index) {
|
||||
final exercise = exercises[index];
|
||||
return _buildExerciseCard(exercise);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _getCrossAxisCount(double width) {
|
||||
if (width > 1200) return 3;
|
||||
if (width > 800) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
Widget _buildExerciseCard(ReadingExercise exercise) {
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToExercise(exercise),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
exercise.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDifficultyColor(exercise.difficulty),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_getDifficultyLabel(exercise.difficulty),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
exercise.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${exercise.estimatedTime}分钟',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.text_fields,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${exercise.wordCount}词',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.quiz,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${exercise.questions.length}题',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (exercise.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
children: exercise.tags.take(3).map((tag) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getDifficultyName(ReadingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case ReadingDifficulty.elementary:
|
||||
return '初级 (A1)';
|
||||
case ReadingDifficulty.intermediate:
|
||||
return '中级 (B1)';
|
||||
case ReadingDifficulty.upperIntermediate:
|
||||
return '中高级 (B2)';
|
||||
case ReadingDifficulty.advanced:
|
||||
return '高级 (C1)';
|
||||
case ReadingDifficulty.proficient:
|
||||
return '精通 (C2)';
|
||||
}
|
||||
}
|
||||
|
||||
String _getDifficultyLabel(ReadingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case ReadingDifficulty.elementary:
|
||||
return 'A1';
|
||||
case ReadingDifficulty.intermediate:
|
||||
return 'B1';
|
||||
case ReadingDifficulty.upperIntermediate:
|
||||
return 'B2';
|
||||
case ReadingDifficulty.advanced:
|
||||
return 'C1';
|
||||
case ReadingDifficulty.proficient:
|
||||
return 'C2';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getDifficultyColor(ReadingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case ReadingDifficulty.elementary:
|
||||
return Colors.green;
|
||||
case ReadingDifficulty.intermediate:
|
||||
return Colors.blue;
|
||||
case ReadingDifficulty.upperIntermediate:
|
||||
return Colors.orange;
|
||||
case ReadingDifficulty.advanced:
|
||||
return Colors.red;
|
||||
case ReadingDifficulty.proficient:
|
||||
return Colors.purple;
|
||||
}
|
||||
}
|
||||
|
||||
String _typeToCategory(ReadingExerciseType type) {
|
||||
switch (type) {
|
||||
case ReadingExerciseType.news:
|
||||
return 'news';
|
||||
case ReadingExerciseType.story:
|
||||
return 'story';
|
||||
case ReadingExerciseType.science:
|
||||
return 'science';
|
||||
case ReadingExerciseType.business:
|
||||
return 'business';
|
||||
case ReadingExerciseType.culture:
|
||||
return 'culture';
|
||||
case ReadingExerciseType.technology:
|
||||
return 'technology';
|
||||
case ReadingExerciseType.health:
|
||||
return 'health';
|
||||
case ReadingExerciseType.travel:
|
||||
return 'travel';
|
||||
}
|
||||
}
|
||||
|
||||
String _difficultyToLevel(ReadingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case ReadingDifficulty.elementary:
|
||||
return 'elementary';
|
||||
case ReadingDifficulty.intermediate:
|
||||
return 'intermediate';
|
||||
case ReadingDifficulty.upperIntermediate:
|
||||
return 'upper-intermediate';
|
||||
case ReadingDifficulty.advanced:
|
||||
return 'advanced';
|
||||
case ReadingDifficulty.proficient:
|
||||
return 'proficient';
|
||||
}
|
||||
}
|
||||
|
||||
String _categoryLabel(ReadingExerciseType type) {
|
||||
switch (type) {
|
||||
case ReadingExerciseType.news:
|
||||
return '新闻';
|
||||
case ReadingExerciseType.story:
|
||||
return '故事';
|
||||
case ReadingExerciseType.science:
|
||||
return '科学';
|
||||
case ReadingExerciseType.business:
|
||||
return '商务';
|
||||
case ReadingExerciseType.culture:
|
||||
return '文化';
|
||||
case ReadingExerciseType.technology:
|
||||
return '科技';
|
||||
case ReadingExerciseType.health:
|
||||
return '健康';
|
||||
case ReadingExerciseType.travel:
|
||||
return '旅游';
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToExercise(ReadingExercise exercise) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ReadingExerciseScreen(exercise: exercise),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
781
client/lib/features/reading/screens/reading_exercise_screen.dart
Normal file
781
client/lib/features/reading/screens/reading_exercise_screen.dart
Normal file
@@ -0,0 +1,781 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/reading_exercise_model.dart';
|
||||
|
||||
/// 阅读练习详情页面
|
||||
class ReadingExerciseScreen extends StatefulWidget {
|
||||
final ReadingExercise exercise;
|
||||
|
||||
const ReadingExerciseScreen({
|
||||
super.key,
|
||||
required this.exercise,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingExerciseScreen> createState() => _ReadingExerciseScreenState();
|
||||
}
|
||||
|
||||
class _ReadingExerciseScreenState extends State<ReadingExerciseScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
Map<String, int> userAnswers = {};
|
||||
bool showResults = false;
|
||||
int score = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(
|
||||
widget.exercise.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: const Color(0xFF2196F3),
|
||||
unselectedLabelColor: Colors.grey,
|
||||
indicatorColor: const Color(0xFF2196F3),
|
||||
tabs: const [
|
||||
Tab(text: '阅读文章'),
|
||||
Tab(text: '练习题'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildArticleTab(),
|
||||
_buildQuestionsTab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArticleTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildArticleHeader(),
|
||||
const SizedBox(height: 20),
|
||||
_buildArticleContent(),
|
||||
const SizedBox(height: 20),
|
||||
_buildArticleFooter(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArticleHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.exercise.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDifficultyColor(widget.exercise.difficulty),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_getDifficultyLabel(widget.exercise.difficulty),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.exercise.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoChip(Icons.access_time, '${widget.exercise.estimatedTime}分钟'),
|
||||
const SizedBox(width: 12),
|
||||
_buildInfoChip(Icons.text_fields, '${widget.exercise.wordCount}词'),
|
||||
const SizedBox(width: 12),
|
||||
_buildInfoChip(Icons.quiz, '${widget.exercise.questions.length}题'),
|
||||
],
|
||||
),
|
||||
if (widget.exercise.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: widget.exercise.tags.map((tag) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(IconData icon, String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArticleContent() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'文章内容',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.exercise.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
height: 1.6,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArticleFooter() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'文章信息',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'来源:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.exercise.source,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'发布时间:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${widget.exercise.publishDate.year}年${widget.exercise.publishDate.month}月${widget.exercise.publishDate.day}日',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuestionsTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showResults) _buildResultsHeader(),
|
||||
...widget.exercise.questions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final question = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _buildQuestionCard(question, index),
|
||||
);
|
||||
}).toList(),
|
||||
const SizedBox(height: 20),
|
||||
if (!showResults)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _submitAnswers,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'提交答案',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showResults)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _resetQuiz,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.grey,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'重新练习',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsHeader() {
|
||||
final percentage = (score / widget.exercise.questions.length * 100).round();
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'练习结果',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
'$score',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'正确题数',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
'${widget.exercise.questions.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'总题数',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
'$percentage%',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: percentage >= 80 ? Colors.green :
|
||||
percentage >= 60 ? Colors.orange : Colors.red,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'正确率',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuestionCard(ReadingQuestion question, int index) {
|
||||
final userAnswer = userAnswers[question.id];
|
||||
final isCorrect = userAnswer == question.correctAnswer;
|
||||
final showAnswer = showResults;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: showAnswer
|
||||
? Border.all(
|
||||
color: isCorrect ? Colors.green : Colors.red,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
question.question,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showAnswer)
|
||||
Icon(
|
||||
isCorrect ? Icons.check_circle : Icons.cancel,
|
||||
color: isCorrect ? Colors.green : Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (question.type == 'multiple_choice')
|
||||
...question.options.asMap().entries.map((entry) {
|
||||
final optionIndex = entry.key;
|
||||
final option = entry.value;
|
||||
final isSelected = userAnswer == optionIndex;
|
||||
final isCorrectOption = optionIndex == question.correctAnswer;
|
||||
|
||||
Color? backgroundColor;
|
||||
Color? textColor;
|
||||
if (showAnswer) {
|
||||
if (isCorrectOption) {
|
||||
backgroundColor = Colors.green.withOpacity(0.1);
|
||||
textColor = Colors.green;
|
||||
} else if (isSelected && !isCorrectOption) {
|
||||
backgroundColor = Colors.red.withOpacity(0.1);
|
||||
textColor = Colors.red;
|
||||
}
|
||||
} else if (isSelected) {
|
||||
backgroundColor = const Color(0xFF2196F3).withOpacity(0.1);
|
||||
textColor = const Color(0xFF2196F3);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: showAnswer ? null : () {
|
||||
setState(() {
|
||||
userAnswers[question.id] = optionIndex;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: backgroundColor != null
|
||||
? (textColor ?? Colors.grey)
|
||||
: Colors.grey.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected ? (textColor ?? const Color(0xFF2196F3)) : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: textColor ?? Colors.grey,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 12,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
option,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: textColor ?? Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
if (question.type == 'true_false')
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildTrueFalseOption(question, true, 'True'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildTrueFalseOption(question, false, 'False'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showAnswer && question.explanation.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'解析:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
question.explanation,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrueFalseOption(ReadingQuestion question, bool value, String label) {
|
||||
final userAnswer = userAnswers[question.id];
|
||||
final isSelected = userAnswer == (value ? 0 : 1);
|
||||
final isCorrect = (value ? 0 : 1) == question.correctAnswer;
|
||||
final showAnswer = showResults;
|
||||
|
||||
Color? backgroundColor;
|
||||
Color? textColor;
|
||||
if (showAnswer) {
|
||||
if (isCorrect) {
|
||||
backgroundColor = Colors.green.withOpacity(0.1);
|
||||
textColor = Colors.green;
|
||||
} else if (isSelected && !isCorrect) {
|
||||
backgroundColor = Colors.red.withOpacity(0.1);
|
||||
textColor = Colors.red;
|
||||
}
|
||||
} else if (isSelected) {
|
||||
backgroundColor = const Color(0xFF2196F3).withOpacity(0.1);
|
||||
textColor = const Color(0xFF2196F3);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: showAnswer ? null : () {
|
||||
setState(() {
|
||||
userAnswers[question.id] = value ? 0 : 1;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: backgroundColor != null
|
||||
? (textColor ?? Colors.grey)
|
||||
: Colors.grey.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor ?? Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _submitAnswers() {
|
||||
if (userAnswers.length < widget.exercise.questions.length) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('请完成所有题目后再提交'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
int correctCount = 0;
|
||||
for (final question in widget.exercise.questions) {
|
||||
if (userAnswers[question.id] == question.correctAnswer) {
|
||||
correctCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
score = correctCount;
|
||||
showResults = true;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('练习完成!正确率:${(correctCount / widget.exercise.questions.length * 100).round()}%'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _resetQuiz() {
|
||||
setState(() {
|
||||
userAnswers.clear();
|
||||
showResults = false;
|
||||
score = 0;
|
||||
});
|
||||
}
|
||||
|
||||
String _getDifficultyLabel(ReadingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case ReadingDifficulty.elementary:
|
||||
return 'A1';
|
||||
case ReadingDifficulty.intermediate:
|
||||
return 'B1';
|
||||
case ReadingDifficulty.upperIntermediate:
|
||||
return 'B2';
|
||||
case ReadingDifficulty.advanced:
|
||||
return 'C1';
|
||||
case ReadingDifficulty.proficient:
|
||||
return 'C2';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getDifficultyColor(ReadingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case ReadingDifficulty.elementary:
|
||||
return Colors.green;
|
||||
case ReadingDifficulty.intermediate:
|
||||
return Colors.orange;
|
||||
case ReadingDifficulty.upperIntermediate:
|
||||
return Colors.deepOrange;
|
||||
case ReadingDifficulty.advanced:
|
||||
return Colors.red;
|
||||
case ReadingDifficulty.proficient:
|
||||
return Colors.purple;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/reading_article.dart';
|
||||
import '../providers/reading_provider.dart';
|
||||
import '../widgets/reading_article_card.dart';
|
||||
import 'reading_article_screen.dart';
|
||||
import 'reading_search_screen.dart';
|
||||
|
||||
/// 阅读收藏页面
|
||||
class ReadingFavoritesScreen extends StatefulWidget {
|
||||
const ReadingFavoritesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ReadingFavoritesScreen> createState() => _ReadingFavoritesScreenState();
|
||||
}
|
||||
|
||||
class _ReadingFavoritesScreenState extends State<ReadingFavoritesScreen> {
|
||||
String _selectedDifficulty = 'all';
|
||||
String _selectedCategory = 'all';
|
||||
String _sortBy = 'newest';
|
||||
bool _isLoading = true;
|
||||
List<ReadingArticle> _filteredFavorites = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadFavorites();
|
||||
}
|
||||
|
||||
/// 加载收藏文章
|
||||
Future<void> _loadFavorites() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final provider = Provider.of<ReadingProvider>(context, listen: false);
|
||||
await provider.loadFavoriteArticles();
|
||||
_applyFilters();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('加载收藏失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用筛选条件
|
||||
void _applyFilters() {
|
||||
final provider = Provider.of<ReadingProvider>(context, listen: false);
|
||||
List<ReadingArticle> favorites = List.from(provider.favoriteArticles);
|
||||
|
||||
// 难度筛选
|
||||
if (_selectedDifficulty != 'all') {
|
||||
favorites = favorites.where((article) =>
|
||||
article.difficulty == _selectedDifficulty
|
||||
).toList();
|
||||
}
|
||||
|
||||
// 分类筛选
|
||||
if (_selectedCategory != 'all') {
|
||||
favorites = favorites.where((article) =>
|
||||
article.category == _selectedCategory
|
||||
).toList();
|
||||
}
|
||||
|
||||
// 排序
|
||||
switch (_sortBy) {
|
||||
case 'newest':
|
||||
favorites.sort((a, b) => b.publishDate.compareTo(a.publishDate));
|
||||
break;
|
||||
case 'oldest':
|
||||
favorites.sort((a, b) => a.publishDate.compareTo(b.publishDate));
|
||||
break;
|
||||
case 'difficulty':
|
||||
favorites.sort((a, b) {
|
||||
const difficultyOrder = {'beginner': 1, 'intermediate': 2, 'advanced': 3};
|
||||
return (difficultyOrder[a.difficulty] ?? 0)
|
||||
.compareTo(difficultyOrder[b.difficulty] ?? 0);
|
||||
});
|
||||
break;
|
||||
case 'wordCount':
|
||||
favorites.sort((a, b) => a.wordCount.compareTo(b.wordCount));
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_filteredFavorites = favorites;
|
||||
});
|
||||
}
|
||||
|
||||
/// 取消收藏
|
||||
Future<void> _unfavoriteArticle(ReadingArticle article) async {
|
||||
try {
|
||||
final provider = Provider.of<ReadingProvider>(context, listen: false);
|
||||
await provider.favoriteArticle(article.id);
|
||||
_applyFilters();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('已取消收藏'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('取消收藏失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[50],
|
||||
appBar: AppBar(
|
||||
title: const Text('我的收藏'),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ReadingSearchScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.sort),
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
_sortBy = value;
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'newest',
|
||||
child: Text('最新收藏'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'oldest',
|
||||
child: Text('最早收藏'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'difficulty',
|
||||
child: Text('按难度'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'wordCount',
|
||||
child: Text('按字数'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 筛选条件
|
||||
_buildFilterSection(),
|
||||
|
||||
// 收藏列表
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF2196F3)),
|
||||
),
|
||||
)
|
||||
: _buildFavoritesList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建筛选条件
|
||||
Widget _buildFilterSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
// 难度筛选
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'难度:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('全部', 'all', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('初级', 'beginner', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('中级', 'intermediate', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('高级', 'advanced', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 分类筛选
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'分类:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('全部', 'all', _selectedCategory, (value) {
|
||||
setState(() {
|
||||
_selectedCategory = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('新闻', 'news', _selectedCategory, (value) {
|
||||
setState(() {
|
||||
_selectedCategory = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('科技', 'technology', _selectedCategory, (value) {
|
||||
setState(() {
|
||||
_selectedCategory = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('商务', 'business', _selectedCategory, (value) {
|
||||
setState(() {
|
||||
_selectedCategory = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('文化', 'culture', _selectedCategory, (value) {
|
||||
setState(() {
|
||||
_selectedCategory = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建筛选标签
|
||||
Widget _buildFilterChip(
|
||||
String label,
|
||||
String value,
|
||||
String selectedValue,
|
||||
Function(String) onSelected,
|
||||
) {
|
||||
final isSelected = selectedValue == value;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => onSelected(value),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF2196F3) : Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected ? const Color(0xFF2196F3) : Colors.grey[300]!,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected ? Colors.white : Colors.grey[700],
|
||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建收藏列表
|
||||
Widget _buildFavoritesList() {
|
||||
if (_filteredFavorites.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.favorite_border,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_selectedDifficulty == 'all' && _selectedCategory == 'all'
|
||||
? '还没有收藏任何文章'
|
||||
: '没有符合条件的收藏文章',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_selectedDifficulty == 'all' && _selectedCategory == 'all'
|
||||
? '去发现一些有趣的文章吧'
|
||||
: '试试调整筛选条件',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
child: const Text('去阅读'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 统计信息
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'共 ${_filteredFavorites.length} 篇收藏',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_selectedDifficulty != 'all' || _selectedCategory != 'all')
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedDifficulty = 'all';
|
||||
_selectedCategory = 'all';
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
child: const Text(
|
||||
'清除筛选',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 文章列表
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _filteredFavorites.length,
|
||||
itemBuilder: (context, index) {
|
||||
final article = _filteredFavorites[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Dismissible(
|
||||
key: Key('favorite_${article.id}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.favorite_border,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'取消收藏',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认取消收藏'),
|
||||
content: Text('确定要取消收藏「${article.title}」吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text(
|
||||
'确定',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ?? false;
|
||||
},
|
||||
onDismissed: (direction) {
|
||||
_unfavoriteArticle(article);
|
||||
},
|
||||
child: ReadingArticleCard(
|
||||
article: article,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ReadingArticleScreen(
|
||||
articleId: article.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
782
client/lib/features/reading/screens/reading_history_screen.dart
Normal file
782
client/lib/features/reading/screens/reading_history_screen.dart
Normal file
@@ -0,0 +1,782 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/reading_article.dart';
|
||||
import '../providers/reading_provider.dart';
|
||||
import '../widgets/reading_article_card.dart';
|
||||
import 'reading_article_screen.dart';
|
||||
import 'reading_search_screen.dart';
|
||||
|
||||
/// 阅读历史页面
|
||||
class ReadingHistoryScreen extends StatefulWidget {
|
||||
const ReadingHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ReadingHistoryScreen> createState() => _ReadingHistoryScreenState();
|
||||
}
|
||||
|
||||
class _ReadingHistoryScreenState extends State<ReadingHistoryScreen> {
|
||||
String _selectedPeriod = 'all';
|
||||
String _selectedDifficulty = 'all';
|
||||
String _sortBy = 'newest';
|
||||
bool _isLoading = true;
|
||||
List<ReadingArticle> _filteredHistory = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHistory();
|
||||
}
|
||||
|
||||
/// 加载阅读历史
|
||||
Future<void> _loadHistory() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final provider = Provider.of<ReadingProvider>(context, listen: false);
|
||||
await provider.loadReadingHistory();
|
||||
_applyFilters();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('加载历史失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用筛选条件
|
||||
void _applyFilters() {
|
||||
final provider = Provider.of<ReadingProvider>(context, listen: false);
|
||||
List<ReadingArticle> history = List.from(provider.readingHistory);
|
||||
|
||||
// 时间筛选
|
||||
if (_selectedPeriod != 'all') {
|
||||
final now = DateTime.now();
|
||||
DateTime cutoffDate;
|
||||
|
||||
switch (_selectedPeriod) {
|
||||
case 'today':
|
||||
cutoffDate = DateTime(now.year, now.month, now.day);
|
||||
break;
|
||||
case 'week':
|
||||
cutoffDate = now.subtract(const Duration(days: 7));
|
||||
break;
|
||||
case 'month':
|
||||
cutoffDate = DateTime(now.year, now.month - 1, now.day);
|
||||
break;
|
||||
default:
|
||||
cutoffDate = DateTime(1970);
|
||||
}
|
||||
|
||||
history = history.where((article) =>
|
||||
article.publishDate.isAfter(cutoffDate)
|
||||
).toList();
|
||||
}
|
||||
|
||||
// 难度筛选
|
||||
if (_selectedDifficulty != 'all') {
|
||||
history = history.where((article) =>
|
||||
article.difficulty == _selectedDifficulty
|
||||
).toList();
|
||||
}
|
||||
|
||||
// 排序
|
||||
switch (_sortBy) {
|
||||
case 'newest':
|
||||
history.sort((a, b) => b.publishDate.compareTo(a.publishDate));
|
||||
break;
|
||||
case 'oldest':
|
||||
history.sort((a, b) => a.publishDate.compareTo(b.publishDate));
|
||||
break;
|
||||
case 'difficulty':
|
||||
history.sort((a, b) {
|
||||
const difficultyOrder = {'beginner': 1, 'intermediate': 2, 'advanced': 3};
|
||||
return (difficultyOrder[a.difficulty] ?? 0)
|
||||
.compareTo(difficultyOrder[b.difficulty] ?? 0);
|
||||
});
|
||||
break;
|
||||
case 'readingTime':
|
||||
history.sort((a, b) => (a.readingTime ?? 0).compareTo(b.readingTime ?? 0));
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_filteredHistory = history;
|
||||
});
|
||||
}
|
||||
|
||||
/// 清除历史记录
|
||||
Future<void> _clearHistory() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('清除历史记录'),
|
||||
content: const Text('确定要清除所有阅读历史记录吗?此操作不可撤销。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text(
|
||||
'确定',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final provider = Provider.of<ReadingProvider>(context, listen: false);
|
||||
// TODO: 实现清除历史记录功能
|
||||
// await provider.clearReadingHistory();
|
||||
_applyFilters();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('历史记录已清除'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('清除失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除单个历史记录
|
||||
Future<void> _removeHistoryItem(ReadingArticle article) async {
|
||||
try {
|
||||
final provider = Provider.of<ReadingProvider>(context, listen: false);
|
||||
// TODO: 实现移除历史记录功能
|
||||
// await provider.removeFromHistory(article.id);
|
||||
_applyFilters();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('已从历史记录中移除'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('移除失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[50],
|
||||
appBar: AppBar(
|
||||
title: const Text('阅读历史'),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ReadingSearchScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
if (value == 'clear') {
|
||||
_clearHistory();
|
||||
} else {
|
||||
setState(() {
|
||||
_sortBy = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'newest',
|
||||
child: Text('最近阅读'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'oldest',
|
||||
child: Text('最早阅读'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'difficulty',
|
||||
child: Text('按难度'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'readingTime',
|
||||
child: Text('按阅读时长'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'clear',
|
||||
child: Text(
|
||||
'清除历史',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 筛选条件
|
||||
_buildFilterSection(),
|
||||
|
||||
// 历史列表
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF2196F3)),
|
||||
),
|
||||
)
|
||||
: _buildHistoryList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建筛选条件
|
||||
Widget _buildFilterSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
// 时间筛选
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'时间:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('全部', 'all', _selectedPeriod, (value) {
|
||||
setState(() {
|
||||
_selectedPeriod = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('今天', 'today', _selectedPeriod, (value) {
|
||||
setState(() {
|
||||
_selectedPeriod = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('本周', 'week', _selectedPeriod, (value) {
|
||||
setState(() {
|
||||
_selectedPeriod = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('本月', 'month', _selectedPeriod, (value) {
|
||||
setState(() {
|
||||
_selectedPeriod = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 难度筛选
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'难度:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('全部', 'all', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('初级', 'beginner', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('中级', 'intermediate', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
_buildFilterChip('高级', 'advanced', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
_applyFilters();
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建筛选标签
|
||||
Widget _buildFilterChip(
|
||||
String label,
|
||||
String value,
|
||||
String selectedValue,
|
||||
Function(String) onSelected,
|
||||
) {
|
||||
final isSelected = selectedValue == value;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => onSelected(value),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF2196F3) : Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected ? const Color(0xFF2196F3) : Colors.grey[300]!,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected ? Colors.white : Colors.grey[700],
|
||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建历史列表
|
||||
Widget _buildHistoryList() {
|
||||
if (_filteredHistory.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_selectedPeriod == 'all' && _selectedDifficulty == 'all'
|
||||
? '还没有阅读历史'
|
||||
: '没有符合条件的阅读记录',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_selectedPeriod == 'all' && _selectedDifficulty == 'all'
|
||||
? '开始你的第一次阅读吧'
|
||||
: '试试调整筛选条件',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
child: const Text('去阅读'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 统计信息
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'共 ${_filteredHistory.length} 条记录',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_selectedPeriod != 'all' || _selectedDifficulty != 'all')
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedPeriod = 'all';
|
||||
_selectedDifficulty = 'all';
|
||||
});
|
||||
_applyFilters();
|
||||
},
|
||||
child: const Text(
|
||||
'清除筛选',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 文章列表
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _filteredHistory.length,
|
||||
itemBuilder: (context, index) {
|
||||
final article = _filteredHistory[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Dismissible(
|
||||
key: Key('history_${article.id}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'删除记录',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('删除记录'),
|
||||
content: Text('确定要删除「${article.title}」的阅读记录吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text(
|
||||
'确定',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ?? false;
|
||||
},
|
||||
onDismissed: (direction) {
|
||||
_removeHistoryItem(article);
|
||||
},
|
||||
child: _buildHistoryCard(article),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建历史记录卡片
|
||||
Widget _buildHistoryCard(ReadingArticle article) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ReadingArticleScreen(
|
||||
articleId: article.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题和时间
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
article.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatDate(article.publishDate),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 摘要
|
||||
Text(
|
||||
article.content.length > 100
|
||||
? '${article.content.substring(0, 100)}...'
|
||||
: article.content,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 标签和进度
|
||||
Row(
|
||||
children: [
|
||||
// 难度标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDifficultyColor(article.difficulty).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_getDifficultyText(article.difficulty),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getDifficultyColor(article.difficulty),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 分类标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
article.category,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 阅读时长
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${article.readingTime}分钟',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化日期
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays == 0) {
|
||||
return '今天';
|
||||
} else if (difference.inDays == 1) {
|
||||
return '昨天';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays}天前';
|
||||
} else if (difference.inDays < 30) {
|
||||
return '${(difference.inDays / 7).floor()}周前';
|
||||
} else {
|
||||
return '${date.month}月${date.day}日';
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取难度颜色
|
||||
Color _getDifficultyColor(String difficulty) {
|
||||
switch (difficulty) {
|
||||
case 'beginner':
|
||||
return Colors.green;
|
||||
case 'intermediate':
|
||||
return Colors.orange;
|
||||
case 'advanced':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取难度文本
|
||||
String _getDifficultyText(String difficulty) {
|
||||
switch (difficulty) {
|
||||
case 'beginner':
|
||||
return '初级';
|
||||
case 'intermediate':
|
||||
return '中级';
|
||||
case 'advanced':
|
||||
return '高级';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
}
|
||||
721
client/lib/features/reading/screens/reading_home_screen.dart
Normal file
721
client/lib/features/reading/screens/reading_home_screen.dart
Normal file
@@ -0,0 +1,721 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/reading_exercise_model.dart';
|
||||
import '../providers/reading_provider.dart';
|
||||
import 'reading_category_screen.dart';
|
||||
import 'reading_exercise_screen.dart';
|
||||
|
||||
/// 阅读理解主页面
|
||||
class ReadingHomeScreen extends ConsumerStatefulWidget {
|
||||
const ReadingHomeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ReadingHomeScreen> createState() => _ReadingHomeScreenState();
|
||||
}
|
||||
|
||||
class _ReadingHomeScreenState extends ConsumerState<ReadingHomeScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载推荐文章
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(readingMaterialsProvider.notifier).loadRecommendedMaterials();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'阅读理解',
|
||||
style: TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildReadingModes(context),
|
||||
const SizedBox(height: 20),
|
||||
_buildArticleCategories(),
|
||||
const SizedBox(height: 20),
|
||||
_buildRecommendedArticles(context),
|
||||
const SizedBox(height: 20),
|
||||
_buildReadingProgress(),
|
||||
const SizedBox(height: 100), // 底部导航栏空间
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadingModes(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'阅读模式',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 在宽屏幕上使用更宽松的布局
|
||||
if (constraints.maxWidth > 600) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildModeCard(
|
||||
context,
|
||||
'休闲阅读',
|
||||
'轻松阅读体验',
|
||||
Icons.book_outlined,
|
||||
Colors.green,
|
||||
() => _navigateToCategory(context, ReadingExerciseType.story),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: _buildModeCard(
|
||||
context,
|
||||
'练习阅读',
|
||||
'结合练习题',
|
||||
Icons.quiz,
|
||||
Colors.blue,
|
||||
() => _showExerciseTypeDialog(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildModeCard(
|
||||
context,
|
||||
'休闲阅读',
|
||||
'轻松阅读体验',
|
||||
Icons.book_outlined,
|
||||
Colors.green,
|
||||
() => _navigateToCategory(context, ReadingExerciseType.story),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildModeCard(
|
||||
context,
|
||||
'练习阅读',
|
||||
'结合练习题',
|
||||
Icons.quiz,
|
||||
Colors.blue,
|
||||
() => _showExerciseTypeDialog(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeCard(BuildContext context, String title, String subtitle, IconData icon, Color color, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArticleCategories() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'文章分类',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final materialsState = ref.watch(readingMaterialsProvider);
|
||||
if (materialsState.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (materialsState.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
|
||||
const SizedBox(height: 12),
|
||||
Text('加载失败: ${materialsState.error}', style: const TextStyle(color: Colors.redAccent)),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(readingMaterialsProvider.notifier).loadRecommendedMaterials();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final materials = materialsState.materials;
|
||||
if (materials.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.category, size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无文章分类', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(readingMaterialsProvider.notifier).loadRecommendedMaterials();
|
||||
},
|
||||
child: const Text('刷新'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final Map<String, int> counts = {};
|
||||
for (final m in materials) {
|
||||
final key = m.type.name;
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
final categories = counts.keys.toList();
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 2.5,
|
||||
),
|
||||
itemCount: categories.length.clamp(0, 4),
|
||||
itemBuilder: (context, index) {
|
||||
final name = categories[index];
|
||||
final count = counts[name] ?? 0;
|
||||
final color = [Colors.blue, Colors.green, Colors.orange, Colors.purple][index % 4];
|
||||
final icon = [Icons.computer, Icons.people, Icons.business_center, Icons.history_edu][index % 4];
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToCategory(context, _mapStringToType(name)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$count篇',
|
||||
style: const TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ReadingExerciseType _mapStringToType(String name) {
|
||||
switch (name.toLowerCase()) {
|
||||
case 'news':
|
||||
return ReadingExerciseType.news;
|
||||
case 'story':
|
||||
return ReadingExerciseType.story;
|
||||
case 'science':
|
||||
return ReadingExerciseType.science;
|
||||
case 'business':
|
||||
return ReadingExerciseType.business;
|
||||
case 'technology':
|
||||
return ReadingExerciseType.technology;
|
||||
default:
|
||||
return ReadingExerciseType.news;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRecommendedArticles(BuildContext context) {
|
||||
final materialsState = ref.watch(readingMaterialsProvider);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'推荐文章',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
materialsState.isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: materialsState.error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
'加载失败: ${materialsState.error}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
)
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final exercises = materialsState.materials;
|
||||
if (exercises.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.article_outlined, size: 48, color: Colors.grey),
|
||||
const SizedBox(height: 12),
|
||||
const Text('暂无推荐文章', style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(readingMaterialsProvider.notifier).loadRecommendedMaterials();
|
||||
},
|
||||
child: const Text('刷新'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (constraints.maxWidth > 800) {
|
||||
// 宽屏幕:使用网格布局
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 3.0,
|
||||
),
|
||||
itemCount: exercises.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildArticleItem(context, exercises[index]);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 窄屏幕:使用列表布局
|
||||
return Column(
|
||||
children: exercises.map((exercise) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildArticleItem(context, exercise),
|
||||
),
|
||||
).toList(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArticleItem(BuildContext context, ReadingExercise exercise) {
|
||||
String getDifficultyLabel(ReadingDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case ReadingDifficulty.elementary:
|
||||
return 'A1-A2';
|
||||
case ReadingDifficulty.intermediate:
|
||||
return 'B1';
|
||||
case ReadingDifficulty.upperIntermediate:
|
||||
return 'B2';
|
||||
case ReadingDifficulty.advanced:
|
||||
return 'C1';
|
||||
case ReadingDifficulty.proficient:
|
||||
return 'C2';
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToExercise(context, exercise),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.article,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
exercise.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
exercise.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${exercise.wordCount}词',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${exercise.estimatedTime}分钟',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
getDifficultyLabel(exercise.difficulty),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadingProgress() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'阅读统计',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final service = ref.watch(readingServiceProvider);
|
||||
return FutureBuilder(
|
||||
future: service.getReadingStats(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
|
||||
const SizedBox(height: 12),
|
||||
Text('获取统计失败: ${snapshot.error}', style: const TextStyle(color: Colors.redAccent)),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: Text('暂无阅读统计', style: TextStyle(color: Colors.grey)));
|
||||
}
|
||||
final stats = snapshot.data!;
|
||||
if (stats.totalArticlesRead == 0) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(Icons.bar_chart, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 12),
|
||||
Text('暂无阅读统计', style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildProgressItem('${stats.totalArticlesRead}', '已读文章', Icons.article),
|
||||
_buildProgressItem('${stats.averageReadingSpeed.toStringAsFixed(0)}', '阅读速度', Icons.speed),
|
||||
_buildProgressItem('${stats.comprehensionAccuracy.toStringAsFixed(0)}%', '理解率', Icons.psychology),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressItem(String value, String label, IconData icon) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: const Color(0xFF2196F3),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 导航到分类页面
|
||||
void _navigateToCategory(BuildContext context, ReadingExerciseType type) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ReadingCategoryScreen(exerciseType: type),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 导航到练习页面
|
||||
void _navigateToExercise(BuildContext context, ReadingExercise exercise) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ReadingExerciseScreen(exercise: exercise),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 显示练习类型选择对话框
|
||||
void _showExerciseTypeDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('选择练习类型'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
{'name': 'news', 'label': '新闻'},
|
||||
{'name': 'story', 'label': '故事'},
|
||||
{'name': 'science', 'label': '科学'},
|
||||
{'name': 'business', 'label': '商务'},
|
||||
{'name': 'technology', 'label': '科技'},
|
||||
].map((category) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.book),
|
||||
title: Text(category['label']!),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_navigateToCategory(context, _mapStringToType(category['name']!));
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
890
client/lib/features/reading/screens/reading_result_screen.dart
Normal file
890
client/lib/features/reading/screens/reading_result_screen.dart
Normal file
@@ -0,0 +1,890 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/reading_question.dart';
|
||||
import '../providers/reading_provider.dart';
|
||||
import '../widgets/reading_article_card.dart';
|
||||
|
||||
/// 阅读练习结果页面
|
||||
class ReadingResultScreen extends StatefulWidget {
|
||||
final ReadingExercise exercise;
|
||||
final String articleTitle;
|
||||
|
||||
const ReadingResultScreen({
|
||||
super.key,
|
||||
required this.exercise,
|
||||
required this.articleTitle,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingResultScreen> createState() => _ReadingResultScreenState();
|
||||
}
|
||||
|
||||
class _ReadingResultScreenState extends State<ReadingResultScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.3),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[50],
|
||||
appBar: AppBar(
|
||||
title: const Text('练习结果'),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
body: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 结果概览卡片
|
||||
_buildResultOverviewCard(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 详细分析
|
||||
_buildDetailedAnalysis(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 问题详情
|
||||
_buildQuestionDetails(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 推荐文章
|
||||
_buildRecommendedArticles(),
|
||||
|
||||
const SizedBox(height: 80), // 为底部按钮留空间
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: _buildBottomActions(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建结果概览卡片
|
||||
Widget _buildResultOverviewCard() {
|
||||
final score = widget.exercise.score ?? 0.0;
|
||||
final totalQuestions = widget.exercise.totalQuestions;
|
||||
final correctAnswers = widget.exercise.correctAnswers;
|
||||
final percentage = (score * 100).round();
|
||||
|
||||
Color scoreColor;
|
||||
String scoreText;
|
||||
IconData scoreIcon;
|
||||
|
||||
if (percentage >= 90) {
|
||||
scoreColor = Colors.green;
|
||||
scoreText = '优秀';
|
||||
scoreIcon = Icons.emoji_events;
|
||||
} else if (percentage >= 80) {
|
||||
scoreColor = Colors.blue;
|
||||
scoreText = '良好';
|
||||
scoreIcon = Icons.thumb_up;
|
||||
} else if (percentage >= 70) {
|
||||
scoreColor = Colors.orange;
|
||||
scoreText = '一般';
|
||||
scoreIcon = Icons.trending_up;
|
||||
} else {
|
||||
scoreColor = Colors.red;
|
||||
scoreText = '需要努力';
|
||||
scoreIcon = Icons.refresh;
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
scoreColor.withOpacity(0.1),
|
||||
scoreColor.withOpacity(0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 分数圆环
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 背景圆环
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
child: CircularProgressIndicator(
|
||||
value: 1.0,
|
||||
strokeWidth: 8,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.grey[200]!,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 进度圆环
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
child: CircularProgressIndicator(
|
||||
value: score,
|
||||
strokeWidth: 8,
|
||||
backgroundColor: Colors.transparent,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(scoreColor),
|
||||
),
|
||||
),
|
||||
// 中心内容
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
scoreIcon,
|
||||
color: scoreColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$percentage%',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scoreColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 评价文本
|
||||
Text(
|
||||
scoreText,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scoreColor,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 详细信息
|
||||
Text(
|
||||
'答对 $correctAnswers / $totalQuestions 题',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
Text(
|
||||
'用时 ${_formatDuration(widget.exercise.duration)}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建详细分析
|
||||
Widget _buildDetailedAnalysis() {
|
||||
final questions = widget.exercise.questions;
|
||||
final multipleChoice = questions.where((q) => q.type == QuestionType.multipleChoice).length;
|
||||
final trueFalse = questions.where((q) => q.type == QuestionType.trueFalse).length;
|
||||
final fillBlank = questions.where((q) => q.type == QuestionType.fillInBlank).length;
|
||||
final shortAnswer = questions.where((q) => q.type == QuestionType.shortAnswer).length;
|
||||
|
||||
final multipleChoiceCorrect = questions
|
||||
.where((q) => q.type == QuestionType.multipleChoice && q.isCorrect == true)
|
||||
.length;
|
||||
final trueFalseCorrect = questions
|
||||
.where((q) => q.type == QuestionType.trueFalse && q.isCorrect == true)
|
||||
.length;
|
||||
final fillBlankCorrect = questions
|
||||
.where((q) => q.type == QuestionType.fillInBlank && q.isCorrect == true)
|
||||
.length;
|
||||
final shortAnswerCorrect = questions
|
||||
.where((q) => q.type == QuestionType.shortAnswer && q.isCorrect == true)
|
||||
.length;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'题型分析',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (multipleChoice > 0)
|
||||
_buildQuestionTypeRow(
|
||||
'选择题',
|
||||
multipleChoiceCorrect,
|
||||
multipleChoice,
|
||||
Icons.radio_button_checked,
|
||||
),
|
||||
|
||||
if (trueFalse > 0)
|
||||
_buildQuestionTypeRow(
|
||||
'判断题',
|
||||
trueFalseCorrect,
|
||||
trueFalse,
|
||||
Icons.check_circle,
|
||||
),
|
||||
|
||||
if (fillBlank > 0)
|
||||
_buildQuestionTypeRow(
|
||||
'填空题',
|
||||
fillBlankCorrect,
|
||||
fillBlank,
|
||||
Icons.edit,
|
||||
),
|
||||
|
||||
if (shortAnswer > 0)
|
||||
_buildQuestionTypeRow(
|
||||
'简答题',
|
||||
shortAnswerCorrect,
|
||||
shortAnswer,
|
||||
Icons.description,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建题型统计行
|
||||
Widget _buildQuestionTypeRow(
|
||||
String type,
|
||||
int correct,
|
||||
int total,
|
||||
IconData icon,
|
||||
) {
|
||||
final percentage = total > 0 ? (correct / total * 100).round() : 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
type,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'$correct/$total',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$percentage%',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: percentage >= 80 ? Colors.green :
|
||||
percentage >= 60 ? Colors.orange : Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建问题详情
|
||||
Widget _buildQuestionDetails() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'题目详情',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_showQuestionDetailsDialog();
|
||||
},
|
||||
child: const Text('查看解析'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
...widget.exercise.questions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final question = entry.value;
|
||||
|
||||
return _buildQuestionSummaryItem(index + 1, question);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建问题摘要项
|
||||
Widget _buildQuestionSummaryItem(int number, ReadingQuestion question) {
|
||||
final isCorrect = question.isCorrect == true;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isCorrect ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isCorrect ? Colors.green.withOpacity(0.3) : Colors.red.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 题号
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isCorrect ? Colors.green : Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$number',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 问题信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_getQuestionTypeText(question.type),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
question.question,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 结果图标
|
||||
Icon(
|
||||
isCorrect ? Icons.check_circle : Icons.cancel,
|
||||
color: isCorrect ? Colors.green : Colors.red,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取问题类型文本
|
||||
String _getQuestionTypeText(QuestionType type) {
|
||||
switch (type) {
|
||||
case QuestionType.multipleChoice:
|
||||
return '选择题';
|
||||
case QuestionType.trueFalse:
|
||||
return '判断题';
|
||||
case QuestionType.fillInBlank:
|
||||
return '填空题';
|
||||
case QuestionType.shortAnswer:
|
||||
return '简答题';
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建推荐文章
|
||||
Widget _buildRecommendedArticles() {
|
||||
return Consumer<ReadingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final recommendations = provider.recommendedArticles;
|
||||
|
||||
if (recommendations.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'推荐阅读',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
...recommendations.take(3).map((article) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ReadingArticleCard(
|
||||
article: article,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
// 导航到文章详情页
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建底部操作按钮
|
||||
Widget _buildBottomActions() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
// 重新练习
|
||||
Navigator.of(context).pop();
|
||||
// 重新开始练习逻辑
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF2196F3),
|
||||
side: const BorderSide(color: Color(0xFF2196F3)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text('重新练习'),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text('完成'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示问题详情对话框
|
||||
void _showQuestionDetailsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 600),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 标题
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF2196F3),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'题目解析',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 内容
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: widget.exercise.questions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final question = widget.exercise.questions[index];
|
||||
return _buildQuestionDetailItem(index + 1, question);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建问题详情项
|
||||
Widget _buildQuestionDetailItem(int number, ReadingQuestion question) {
|
||||
final isCorrect = question.isCorrect == true;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 题号和类型
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isCorrect ? Colors.green : Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'第$number题',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_getQuestionTypeText(question.type),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
isCorrect ? Icons.check_circle : Icons.cancel,
|
||||
color: isCorrect ? Colors.green : Colors.red,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 问题
|
||||
Text(
|
||||
question.question,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 选项(如果有)
|
||||
if (question.options.isNotEmpty) ...
|
||||
question.options.asMap().entries.map((entry) {
|
||||
final optionIndex = entry.key;
|
||||
final option = entry.value;
|
||||
final optionLetter = String.fromCharCode(65 + optionIndex);
|
||||
final isUserAnswer = question.userAnswer == optionLetter;
|
||||
final isCorrectAnswer = question.correctAnswer == optionLetter;
|
||||
|
||||
Color? backgroundColor;
|
||||
Color? textColor;
|
||||
|
||||
if (isCorrectAnswer) {
|
||||
backgroundColor = Colors.green.withOpacity(0.1);
|
||||
textColor = Colors.green[700];
|
||||
} else if (isUserAnswer && !isCorrectAnswer) {
|
||||
backgroundColor = Colors.red.withOpacity(0.1);
|
||||
textColor = Colors.red[700];
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'$optionLetter. ',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textColor ?? Colors.black87,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
option,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: textColor ?? Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isCorrectAnswer)
|
||||
const Icon(
|
||||
Icons.check,
|
||||
color: Colors.green,
|
||||
size: 16,
|
||||
),
|
||||
if (isUserAnswer && !isCorrectAnswer)
|
||||
const Icon(
|
||||
Icons.close,
|
||||
color: Colors.red,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
// 用户答案和正确答案
|
||||
if (question.type != QuestionType.multipleChoice) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (question.userAnswer?.isNotEmpty == true)
|
||||
Text(
|
||||
'你的答案:${question.userAnswer}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isCorrect ? Colors.green[700] : Colors.red[700],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'正确答案:${question.correctAnswer}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.green[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// 解析
|
||||
if (question.explanation.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'解析',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
question.explanation,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化持续时间
|
||||
String _formatDuration(Duration? duration) {
|
||||
if (duration == null) return '未知';
|
||||
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
|
||||
if (minutes > 0) {
|
||||
return '${minutes}分${seconds}秒';
|
||||
} else {
|
||||
return '${seconds}秒';
|
||||
}
|
||||
}
|
||||
}
|
||||
684
client/lib/features/reading/screens/reading_search_screen.dart
Normal file
684
client/lib/features/reading/screens/reading_search_screen.dart
Normal file
@@ -0,0 +1,684 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/reading_article.dart';
|
||||
import '../providers/reading_provider.dart';
|
||||
import '../widgets/reading_article_card.dart';
|
||||
import '../widgets/reading_search_bar.dart';
|
||||
import 'reading_article_screen.dart';
|
||||
|
||||
/// 阅读搜索页面
|
||||
class ReadingSearchScreen extends StatefulWidget {
|
||||
final String? initialQuery;
|
||||
|
||||
const ReadingSearchScreen({
|
||||
super.key,
|
||||
this.initialQuery,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingSearchScreen> createState() => _ReadingSearchScreenState();
|
||||
}
|
||||
|
||||
class _ReadingSearchScreenState extends State<ReadingSearchScreen> {
|
||||
late TextEditingController _searchController;
|
||||
String _currentQuery = '';
|
||||
bool _isSearching = false;
|
||||
List<ReadingArticle> _searchResults = [];
|
||||
List<String> _searchHistory = [];
|
||||
String _selectedDifficulty = 'all';
|
||||
String _selectedCategory = 'all';
|
||||
String _sortBy = 'relevance';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController(text: widget.initialQuery);
|
||||
_currentQuery = widget.initialQuery ?? '';
|
||||
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_performSearch(_currentQuery);
|
||||
});
|
||||
}
|
||||
|
||||
_loadSearchHistory();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 加载搜索历史
|
||||
void _loadSearchHistory() {
|
||||
// TODO: 从本地存储加载搜索历史
|
||||
_searchHistory = [
|
||||
'四级阅读',
|
||||
'商务英语',
|
||||
'科技文章',
|
||||
'新闻报道',
|
||||
];
|
||||
}
|
||||
|
||||
/// 保存搜索历史
|
||||
void _saveSearchHistory(String query) {
|
||||
if (query.trim().isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_searchHistory.remove(query);
|
||||
_searchHistory.insert(0, query);
|
||||
if (_searchHistory.length > 10) {
|
||||
_searchHistory = _searchHistory.take(10).toList();
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: 保存到本地存储
|
||||
}
|
||||
|
||||
/// 执行搜索
|
||||
Future<void> _performSearch(String query) async {
|
||||
if (query.trim().isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
_currentQuery = query;
|
||||
});
|
||||
|
||||
_saveSearchHistory(query);
|
||||
|
||||
try {
|
||||
final provider = Provider.of<ReadingProvider>(context, listen: false);
|
||||
await provider.searchArticles(query);
|
||||
|
||||
setState(() {
|
||||
_searchResults = provider.articles;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('搜索失败: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除搜索历史
|
||||
void _clearSearchHistory() {
|
||||
setState(() {
|
||||
_searchHistory.clear();
|
||||
});
|
||||
// TODO: 清除本地存储
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[50],
|
||||
appBar: AppBar(
|
||||
title: const Text('搜索文章'),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
elevation: 0,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索文章标题、内容或标签...',
|
||||
hintStyle: TextStyle(color: Colors.grey[500]),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_currentQuery = '';
|
||||
_searchResults.clear();
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
},
|
||||
onSubmitted: _performSearch,
|
||||
textInputAction: TextInputAction.search,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 筛选条件
|
||||
_buildFilterSection(),
|
||||
|
||||
// 搜索结果或历史
|
||||
Expanded(
|
||||
child: _currentQuery.isEmpty
|
||||
? _buildSearchHistory()
|
||||
: _buildSearchResults(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建筛选条件
|
||||
Widget _buildFilterSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
// 难度筛选
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'难度:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('全部', 'all', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
_buildFilterChip('初级', 'beginner', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
_buildFilterChip('中级', 'intermediate', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
_buildFilterChip('高级', 'advanced', _selectedDifficulty, (value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 分类筛选
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'分类:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('全部', 'all', _selectedCategory, (value) {
|
||||
setState(() {
|
||||
_selectedCategory = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
_buildFilterChip('新闻', 'news', _selectedCategory, (value) {
|
||||
setState(() {
|
||||
_selectedCategory = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
_buildFilterChip('科技', 'technology', _selectedCategory, (value) {
|
||||
setState(() {
|
||||
_selectedCategory = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
_buildFilterChip('商务', 'business', _selectedCategory, (value) {
|
||||
setState(() {
|
||||
_selectedCategory = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
_buildFilterChip('文化', 'culture', _selectedCategory, (value) {
|
||||
setState(() {
|
||||
_selectedCategory = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 排序方式
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'排序:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('相关度', 'relevance', _sortBy, (value) {
|
||||
setState(() {
|
||||
_sortBy = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
_buildFilterChip('最新', 'newest', _sortBy, (value) {
|
||||
setState(() {
|
||||
_sortBy = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
_buildFilterChip('热门', 'popular', _sortBy, (value) {
|
||||
setState(() {
|
||||
_sortBy = value;
|
||||
});
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建筛选标签
|
||||
Widget _buildFilterChip(
|
||||
String label,
|
||||
String value,
|
||||
String selectedValue,
|
||||
Function(String) onSelected,
|
||||
) {
|
||||
final isSelected = selectedValue == value;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => onSelected(value),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF2196F3) : Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected ? const Color(0xFF2196F3) : Colors.grey[300]!,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected ? Colors.white : Colors.grey[700],
|
||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建搜索历史
|
||||
Widget _buildSearchHistory() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 热门搜索
|
||||
_buildHotSearches(),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 搜索历史
|
||||
if (_searchHistory.isNotEmpty) _buildHistorySection(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建热门搜索
|
||||
Widget _buildHotSearches() {
|
||||
final hotSearches = [
|
||||
'四级阅读',
|
||||
'六级阅读',
|
||||
'托福阅读',
|
||||
'雅思阅读',
|
||||
'商务英语',
|
||||
'日常对话',
|
||||
'科技文章',
|
||||
'新闻报道',
|
||||
'文化差异',
|
||||
'环境保护',
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'热门搜索',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: hotSearches.map((search) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_searchController.text = search;
|
||||
_performSearch(search);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.trending_up,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
search,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建历史搜索
|
||||
Widget _buildHistorySection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'搜索历史',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _clearSearchHistory,
|
||||
child: Text(
|
||||
'清空',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
..._searchHistory.map((history) {
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.history,
|
||||
color: Colors.grey[500],
|
||||
size: 20,
|
||||
),
|
||||
title: Text(
|
||||
history,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: Colors.grey[500],
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchHistory.remove(history);
|
||||
});
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
_searchController.text = history;
|
||||
_performSearch(history);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建搜索结果
|
||||
Widget _buildSearchResults() {
|
||||
if (_isSearching) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF2196F3)),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'搜索中...',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_searchResults.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'没有找到相关文章',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'试试其他关键词或调整筛选条件',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 结果统计
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.white,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'找到 ${_searchResults.length} 篇相关文章',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'搜索"$_currentQuery"',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 文章列表
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _searchResults.length,
|
||||
itemBuilder: (context, index) {
|
||||
final article = _searchResults[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: ReadingArticleCard(
|
||||
article: article,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ReadingArticleScreen(
|
||||
articleId: article.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
280
client/lib/features/reading/services/reading_service.dart
Normal file
280
client/lib/features/reading/services/reading_service.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_endpoints.dart';
|
||||
import '../../../core/services/enhanced_api_service.dart';
|
||||
import '../../../core/models/api_response.dart';
|
||||
import '../models/reading_article.dart';
|
||||
import '../models/reading_question.dart';
|
||||
import '../models/reading_stats.dart';
|
||||
|
||||
/// 阅读服务类
|
||||
class ReadingService {
|
||||
final ApiClient _apiClient = ApiClient.instance;
|
||||
final EnhancedApiService _enhancedApiService = EnhancedApiService();
|
||||
|
||||
// 缓存时长配置
|
||||
static const Duration _shortCacheDuration = Duration(minutes: 5);
|
||||
static const Duration _longCacheDuration = Duration(hours: 1);
|
||||
|
||||
/// 获取文章列表
|
||||
Future<List<ReadingArticle>> getArticles({
|
||||
String? category,
|
||||
String? difficulty,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<List<ReadingArticle>>(
|
||||
ApiEndpoints.readingMaterials,
|
||||
queryParameters: {
|
||||
if (category != null) 'category': category,
|
||||
if (difficulty != null) 'level': difficulty,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
},
|
||||
cacheDuration: _shortCacheDuration,
|
||||
fromJson: (data) {
|
||||
final List<dynamic> list = data['data'] ?? [];
|
||||
return list.map((json) => ReadingArticle.fromJson(json)).toList();
|
||||
},
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('获取文章列表失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取单篇文章详情
|
||||
Future<ReadingArticle> getArticle(String articleId) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<ReadingArticle>(
|
||||
'${ApiEndpoints.readingMaterials}/$articleId',
|
||||
cacheDuration: _longCacheDuration,
|
||||
fromJson: (data) => ReadingArticle.fromJson(data['data']),
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('获取文章详情失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取文章练习题
|
||||
Future<ReadingExercise> getArticleExercise(String articleId) async {
|
||||
try {
|
||||
final response = await _enhancedApiService.get<ReadingExercise>(
|
||||
'${ApiEndpoints.reading}/exercises/$articleId',
|
||||
cacheDuration: _longCacheDuration,
|
||||
fromJson: (data) => ReadingExercise.fromJson(data['data']),
|
||||
);
|
||||
|
||||
if (response.success && response.data != null) {
|
||||
return response.data!;
|
||||
} else {
|
||||
throw Exception(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('获取练习题失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交练习答案
|
||||
Future<ReadingExercise> submitExercise(ReadingExercise exercise) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
'${ApiEndpoints.reading}/exercises/${exercise.id}/submit',
|
||||
data: exercise.toJson(),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return ReadingExercise.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw Exception('Failed to submit exercise');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error submitting exercise: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录阅读进度
|
||||
Future<void> recordReadingProgress({
|
||||
required String articleId,
|
||||
required int readingTime,
|
||||
required bool completed,
|
||||
double? comprehensionScore,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
'${ApiEndpoints.readingRecords}',
|
||||
data: {
|
||||
'article_id': articleId,
|
||||
'reading_time': readingTime,
|
||||
'completed': completed,
|
||||
if (comprehensionScore != null) 'comprehension_score': comprehensionScore,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to record progress');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error recording progress: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取阅读统计
|
||||
Future<ReadingStats> getReadingStats() async {
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
ApiEndpoints.readingStats,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return ReadingStats.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw Exception('Failed to load reading stats');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error fetching reading stats: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取推荐文章
|
||||
Future<List<ReadingArticle>> getRecommendedArticles({int limit = 10}) async {
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
'${ApiEndpoints.reading}/recommendations',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data['data'];
|
||||
return data.map((json) => ReadingArticle.fromJson(json)).toList();
|
||||
} else {
|
||||
throw Exception('Failed to load recommended articles');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error fetching recommended articles: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索文章
|
||||
Future<List<ReadingArticle>> searchArticles({
|
||||
required String query,
|
||||
String? category,
|
||||
String? difficulty,
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
'${ApiEndpoints.readingMaterials}/search',
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
if (category != null) 'category': category,
|
||||
if (difficulty != null) 'difficulty': difficulty,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data['data'];
|
||||
return data.map((json) => ReadingArticle.fromJson(json)).toList();
|
||||
} else {
|
||||
throw Exception('Failed to search articles');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error searching articles: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 收藏文章
|
||||
Future<void> favoriteArticle(String articleId) async {
|
||||
try {
|
||||
final response = await _apiClient.post(
|
||||
'${ApiEndpoints.readingMaterials}/$articleId/favorite',
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to favorite article');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error favoriting article: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消收藏文章
|
||||
Future<void> unfavoriteArticle(String articleId) async {
|
||||
try {
|
||||
final response = await _apiClient.delete(
|
||||
'${ApiEndpoints.readingMaterials}/$articleId/favorite',
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to unfavorite article');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error unfavoriting article: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取收藏文章
|
||||
Future<List<ReadingArticle>> getFavoriteArticles({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
'${ApiEndpoints.readingMaterials}/favorites',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data['data'];
|
||||
return data.map((json) => ReadingArticle.fromJson(json)).toList();
|
||||
} else {
|
||||
throw Exception('Failed to load favorite articles');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error fetching favorite articles: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取阅读历史
|
||||
Future<List<ReadingArticle>> getReadingHistory({
|
||||
int page = 1,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
'${ApiEndpoints.readingRecords}',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data['data'];
|
||||
return data.map((json) => ReadingArticle.fromJson(json)).toList();
|
||||
} else {
|
||||
throw Exception('Failed to load reading history');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error fetching reading history: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
274
client/lib/features/reading/widgets/reading_article_card.dart
Normal file
274
client/lib/features/reading/widgets/reading_article_card.dart
Normal file
@@ -0,0 +1,274 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/reading_article.dart';
|
||||
|
||||
/// 阅读文章卡片组件
|
||||
class ReadingArticleCard extends StatelessWidget {
|
||||
final ReadingArticle article;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onFavorite;
|
||||
final bool showProgress;
|
||||
|
||||
const ReadingArticleCard({
|
||||
super.key,
|
||||
required this.article,
|
||||
this.onTap,
|
||||
this.onFavorite,
|
||||
this.showProgress = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题和收藏按钮
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
article.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (onFavorite != null)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.favorite_border,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: onFavorite,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 24,
|
||||
minHeight: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 文章摘要
|
||||
if (article.content.isNotEmpty)
|
||||
Text(
|
||||
_getExcerpt(article.content),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 标签行
|
||||
Row(
|
||||
children: [
|
||||
// 分类标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
article.categoryLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF2196F3),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 难度标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDifficultyColor(article.difficulty).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
article.difficultyLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getDifficultyColor(article.difficulty),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 字数和阅读时间
|
||||
Text(
|
||||
'${article.wordCount}词',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${article.readingTime}分钟',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 进度条(如果需要显示)
|
||||
if (showProgress && article.isCompleted)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'已完成',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (article.comprehensionScore != null)
|
||||
Text(
|
||||
'得分: ${article.comprehensionScore}分',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getScoreColor(article.comprehensionScore!.toInt()),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: 1.0,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_getScoreColor((article.comprehensionScore ?? 0).toInt()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 标签(如果有)
|
||||
if (article.tags.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: article.tags.take(3).map((tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'#$tag',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取文章摘要
|
||||
String _getExcerpt(String content) {
|
||||
// 移除HTML标签和多余空白
|
||||
String cleanContent = content
|
||||
.replaceAll(RegExp(r'<[^>]*>'), '')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
// 截取前100个字符作为摘要
|
||||
if (cleanContent.length <= 100) {
|
||||
return cleanContent;
|
||||
}
|
||||
|
||||
return '${cleanContent.substring(0, 100)}...';
|
||||
}
|
||||
|
||||
/// 获取难度颜色
|
||||
Color _getDifficultyColor(String difficulty) {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'a1':
|
||||
case 'a2':
|
||||
return Colors.green;
|
||||
case 'b1':
|
||||
case 'b2':
|
||||
return Colors.orange;
|
||||
case 'c1':
|
||||
case 'c2':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取分数颜色
|
||||
Color _getScoreColor(int score) {
|
||||
if (score >= 90) {
|
||||
return Colors.green;
|
||||
} else if (score >= 70) {
|
||||
return Colors.orange;
|
||||
} else {
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
}
|
||||
240
client/lib/features/reading/widgets/reading_category_tabs.dart
Normal file
240
client/lib/features/reading/widgets/reading_category_tabs.dart
Normal file
@@ -0,0 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/reading_provider.dart';
|
||||
|
||||
/// 阅读分类标签组件
|
||||
class ReadingCategoryTabs extends StatelessWidget {
|
||||
const ReadingCategoryTabs({super.key});
|
||||
|
||||
static const List<Map<String, String>> categories = [
|
||||
{'key': '', 'label': '全部'},
|
||||
{'key': 'cet4', 'label': '四级'},
|
||||
{'key': 'cet6', 'label': '六级'},
|
||||
{'key': 'toefl', 'label': '托福'},
|
||||
{'key': 'ielts', 'label': '雅思'},
|
||||
{'key': 'daily', 'label': '日常'},
|
||||
{'key': 'business', 'label': '商务'},
|
||||
{'key': 'academic', 'label': '学术'},
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 50,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
),
|
||||
child: Consumer<ReadingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
final isSelected = provider.selectedCategory == category['key'] ||
|
||||
(provider.selectedCategory == null && category['key'] == '');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _buildCategoryChip(
|
||||
context,
|
||||
category['label']!,
|
||||
category['key']!,
|
||||
isSelected,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建分类标签
|
||||
Widget _buildCategoryChip(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String key,
|
||||
bool isSelected,
|
||||
ReadingProvider provider,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final selectedKey = key.isEmpty ? null : key;
|
||||
provider.setFilter(category: selectedKey);
|
||||
provider.loadArticles(refresh: true);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFF2196F3)
|
||||
: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF2196F3)
|
||||
: Colors.grey[300]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: Colors.grey[700],
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读难度筛选组件
|
||||
class ReadingDifficultyFilter extends StatelessWidget {
|
||||
const ReadingDifficultyFilter({super.key});
|
||||
|
||||
static const List<Map<String, String>> difficulties = [
|
||||
{'key': '', 'label': '全部难度'},
|
||||
{'key': 'a1', 'label': 'A1'},
|
||||
{'key': 'a2', 'label': 'A2'},
|
||||
{'key': 'b1', 'label': 'B1'},
|
||||
{'key': 'b2', 'label': 'B2'},
|
||||
{'key': 'c1', 'label': 'C1'},
|
||||
{'key': 'c2', 'label': 'C2'},
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 50,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
),
|
||||
child: Consumer<ReadingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: difficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
final difficulty = difficulties[index];
|
||||
final isSelected = provider.selectedDifficulty == difficulty['key'] ||
|
||||
(provider.selectedDifficulty == null && difficulty['key'] == '');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _buildDifficultyChip(
|
||||
context,
|
||||
difficulty['label']!,
|
||||
difficulty['key']!,
|
||||
isSelected,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建难度标签
|
||||
Widget _buildDifficultyChip(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String key,
|
||||
bool isSelected,
|
||||
ReadingProvider provider,
|
||||
) {
|
||||
final color = _getDifficultyColor(key);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final selectedKey = key.isEmpty ? null : key;
|
||||
provider.setFilter(difficulty: selectedKey);
|
||||
provider.loadArticles(refresh: true);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? color
|
||||
: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: color,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: color,
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取难度颜色
|
||||
Color _getDifficultyColor(String difficulty) {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'a1':
|
||||
case 'a2':
|
||||
return Colors.green;
|
||||
case 'b1':
|
||||
case 'b2':
|
||||
return Colors.orange;
|
||||
case 'c1':
|
||||
case 'c2':
|
||||
return Colors.red;
|
||||
default:
|
||||
return const Color(0xFF2196F3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 组合的分类和难度筛选组件
|
||||
class ReadingFilterTabs extends StatelessWidget {
|
||||
const ReadingFilterTabs({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const ReadingCategoryTabs(),
|
||||
const ReadingDifficultyFilter(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
209
client/lib/features/reading/widgets/reading_content_widget.dart
Normal file
209
client/lib/features/reading/widgets/reading_content_widget.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/reading_article.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
|
||||
/// 阅读内容组件
|
||||
class ReadingContentWidget extends StatefulWidget {
|
||||
final ReadingArticle article;
|
||||
final ScrollController? scrollController;
|
||||
final VoidCallback? onWordTap;
|
||||
final Function(String)? onTextSelection;
|
||||
|
||||
const ReadingContentWidget({
|
||||
super.key,
|
||||
required this.article,
|
||||
this.scrollController,
|
||||
this.onWordTap,
|
||||
this.onTextSelection,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingContentWidget> createState() => _ReadingContentWidgetState();
|
||||
}
|
||||
|
||||
class _ReadingContentWidgetState extends State<ReadingContentWidget> {
|
||||
double _fontSize = 16.0;
|
||||
double _lineHeight = 1.6;
|
||||
bool _isDarkMode = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 文章标题
|
||||
Text(
|
||||
widget.article.title,
|
||||
style: AppTextStyles.headlineMedium.copyWith(
|
||||
fontSize: _fontSize + 4,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _isDarkMode ? AppColors.onSurface : AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingMd),
|
||||
|
||||
// 文章信息
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category_outlined,
|
||||
size: 16,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
widget.article.category,
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
Icon(
|
||||
Icons.schedule_outlined,
|
||||
size: 16,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'${widget.article.estimatedReadingTime} 分钟',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
Icon(
|
||||
Icons.text_fields_outlined,
|
||||
size: 16,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'${widget.article.wordCount} 词',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 阅读设置工具栏
|
||||
_buildReadingToolbar(),
|
||||
const SizedBox(height: AppDimensions.spacingMd),
|
||||
|
||||
// 文章内容
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: widget.scrollController,
|
||||
child: SelectableText(
|
||||
widget.article.content,
|
||||
style: AppTextStyles.bodyLarge.copyWith(
|
||||
fontSize: _fontSize,
|
||||
height: _lineHeight,
|
||||
color: _isDarkMode ? AppColors.onSurface : AppColors.onSurface,
|
||||
),
|
||||
onSelectionChanged: (selection, cause) {
|
||||
if (selection.isValid && widget.onTextSelection != null) {
|
||||
final selectedText = widget.article.content
|
||||
.substring(selection.start, selection.end);
|
||||
widget.onTextSelection!(selectedText);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadingToolbar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.spacingSm,
|
||||
vertical: AppDimensions.spacingXs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// 字体大小调节
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_fontSize > 12) _fontSize -= 1;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.text_decrease),
|
||||
iconSize: 20,
|
||||
),
|
||||
Text(
|
||||
'${_fontSize.toInt()}',
|
||||
style: AppTextStyles.bodySmall,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_fontSize < 24) _fontSize += 1;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.text_increase),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 行间距调节
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_lineHeight > 1.2) _lineHeight -= 0.1;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.format_line_spacing),
|
||||
iconSize: 20,
|
||||
),
|
||||
Text(
|
||||
'${(_lineHeight * 10).toInt() / 10}',
|
||||
style: AppTextStyles.bodySmall,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_lineHeight < 2.0) _lineHeight += 0.1;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.format_line_spacing),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 夜间模式切换
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isDarkMode = !_isDarkMode;
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
_isDarkMode ? Icons.light_mode : Icons.dark_mode,
|
||||
),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
240
client/lib/features/reading/widgets/reading_progress_bar.dart
Normal file
240
client/lib/features/reading/widgets/reading_progress_bar.dart
Normal file
@@ -0,0 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
|
||||
/// 阅读进度条组件
|
||||
class ReadingProgressBar extends StatelessWidget {
|
||||
final int current;
|
||||
final int total;
|
||||
final double? progress;
|
||||
final String? label;
|
||||
final bool showPercentage;
|
||||
final Color? progressColor;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const ReadingProgressBar({
|
||||
super.key,
|
||||
required this.current,
|
||||
required this.total,
|
||||
this.progress,
|
||||
this.label,
|
||||
this.showPercentage = true,
|
||||
this.progressColor,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progressValue = progress ?? (total > 0 ? current / total : 0.0);
|
||||
final percentage = (progressValue * 100).toInt();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题和进度信息
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label ?? '进度',
|
||||
style: AppTextStyles.titleMedium,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'$current / $total',
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (showPercentage) ...[
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'$percentage%',
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: progressColor ?? AppColors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
|
||||
// 进度条
|
||||
LinearProgressIndicator(
|
||||
value: progressValue,
|
||||
backgroundColor: backgroundColor ?? AppColors.surfaceVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
progressColor ?? AppColors.primary,
|
||||
),
|
||||
minHeight: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 圆形进度条组件
|
||||
class ReadingCircularProgressBar extends StatelessWidget {
|
||||
final int current;
|
||||
final int total;
|
||||
final double? progress;
|
||||
final double size;
|
||||
final double strokeWidth;
|
||||
final Color? progressColor;
|
||||
final Color? backgroundColor;
|
||||
final Widget? child;
|
||||
|
||||
const ReadingCircularProgressBar({
|
||||
super.key,
|
||||
required this.current,
|
||||
required this.total,
|
||||
this.progress,
|
||||
this.size = 80,
|
||||
this.strokeWidth = 6,
|
||||
this.progressColor,
|
||||
this.backgroundColor,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progressValue = progress ?? (total > 0 ? current / total : 0.0);
|
||||
final percentage = (progressValue * 100).toInt();
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 圆形进度条
|
||||
CircularProgressIndicator(
|
||||
value: progressValue,
|
||||
strokeWidth: strokeWidth,
|
||||
backgroundColor: backgroundColor ?? AppColors.surfaceVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
progressColor ?? AppColors.primary,
|
||||
),
|
||||
),
|
||||
|
||||
// 中心内容
|
||||
child ??
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$percentage%',
|
||||
style: AppTextStyles.titleMedium.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: progressColor ?? AppColors.primary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$current/$total',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 步骤进度条组件
|
||||
class ReadingStepProgressBar extends StatelessWidget {
|
||||
final int currentStep;
|
||||
final int totalSteps;
|
||||
final List<String>? stepLabels;
|
||||
final Color? activeColor;
|
||||
final Color? inactiveColor;
|
||||
final Color? completedColor;
|
||||
|
||||
const ReadingStepProgressBar({
|
||||
super.key,
|
||||
required this.currentStep,
|
||||
required this.totalSteps,
|
||||
this.stepLabels,
|
||||
this.activeColor,
|
||||
this.inactiveColor,
|
||||
this.completedColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
child: Row(
|
||||
children: List.generate(totalSteps, (index) {
|
||||
final stepNumber = index + 1;
|
||||
final isCompleted = stepNumber < currentStep;
|
||||
final isActive = stepNumber == currentStep;
|
||||
final isInactive = stepNumber > currentStep;
|
||||
|
||||
Color stepColor;
|
||||
if (isCompleted) {
|
||||
stepColor = completedColor ?? AppColors.success;
|
||||
} else if (isActive) {
|
||||
stepColor = activeColor ?? AppColors.primary;
|
||||
} else {
|
||||
stepColor = inactiveColor ?? AppColors.surfaceVariant;
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// 步骤圆圈
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: stepColor,
|
||||
),
|
||||
child: Center(
|
||||
child: isCompleted
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: AppColors.onPrimary,
|
||||
size: 16,
|
||||
)
|
||||
: Text(
|
||||
stepNumber.toString(),
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: isInactive
|
||||
? AppColors.onSurfaceVariant
|
||||
: AppColors.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 连接线(除了最后一个步骤)
|
||||
if (index < totalSteps - 1)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 2,
|
||||
color: isCompleted
|
||||
? (completedColor ?? AppColors.success)
|
||||
: (inactiveColor ?? AppColors.surfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
335
client/lib/features/reading/widgets/reading_question_widget.dart
Normal file
335
client/lib/features/reading/widgets/reading_question_widget.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
import '../models/reading_question.dart';
|
||||
|
||||
/// 阅读问题组件
|
||||
class ReadingQuestionWidget extends StatelessWidget {
|
||||
final ReadingQuestion question;
|
||||
final String? selectedAnswer;
|
||||
final bool showResult;
|
||||
final Function(String) onAnswerSelected;
|
||||
final VoidCallback? onNext;
|
||||
final VoidCallback? onPrevious;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
|
||||
const ReadingQuestionWidget({
|
||||
super.key,
|
||||
required this.question,
|
||||
this.selectedAnswer,
|
||||
this.showResult = false,
|
||||
required this.onAnswerSelected,
|
||||
this.onNext,
|
||||
this.onPrevious,
|
||||
this.isFirst = false,
|
||||
this.isLast = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingLg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 问题类型标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.spacingSm,
|
||||
vertical: AppDimensions.spacingXs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getQuestionTypeColor(question.type).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusSm),
|
||||
border: Border.all(
|
||||
color: _getQuestionTypeColor(question.type),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_getQuestionTypeLabel(question.type),
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: _getQuestionTypeColor(question.type),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingMd),
|
||||
|
||||
// 问题内容
|
||||
Text(
|
||||
question.question,
|
||||
style: AppTextStyles.titleMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 选项列表
|
||||
...question.options.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final option = entry.value;
|
||||
final optionKey = String.fromCharCode(65 + index.toInt()); // A, B, C, D
|
||||
final isSelected = selectedAnswer == optionKey;
|
||||
final isCorrect = showResult && question.correctAnswer == optionKey;
|
||||
final isWrong = showResult && isSelected && !isCorrect;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppDimensions.spacingSm),
|
||||
child: InkWell(
|
||||
onTap: showResult ? null : () => onAnswerSelected(optionKey),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: _getOptionBackgroundColor(
|
||||
isSelected,
|
||||
isCorrect,
|
||||
isWrong,
|
||||
showResult,
|
||||
),
|
||||
border: Border.all(
|
||||
color: _getOptionBorderColor(
|
||||
isSelected,
|
||||
isCorrect,
|
||||
isWrong,
|
||||
showResult,
|
||||
),
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 选项标识
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _getOptionLabelColor(
|
||||
isSelected,
|
||||
isCorrect,
|
||||
isWrong,
|
||||
showResult,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
optionKey,
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: _getOptionLabelTextColor(
|
||||
isSelected,
|
||||
isCorrect,
|
||||
isWrong,
|
||||
showResult,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
|
||||
// 选项内容
|
||||
Expanded(
|
||||
child: Text(
|
||||
option,
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: _getOptionTextColor(
|
||||
isSelected,
|
||||
isCorrect,
|
||||
isWrong,
|
||||
showResult,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 结果图标
|
||||
if (showResult && (isCorrect || isWrong))
|
||||
Icon(
|
||||
isCorrect ? Icons.check_circle : Icons.cancel,
|
||||
color: isCorrect ? AppColors.success : AppColors.error,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
// 解析(如果显示结果且有解析)
|
||||
if (showResult && question.explanation != null) ...[
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.info.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
border: Border.all(
|
||||
color: AppColors.info.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
color: AppColors.info,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'解析',
|
||||
style: AppTextStyles.titleSmall.copyWith(
|
||||
color: AppColors.info,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
Text(
|
||||
question.explanation!,
|
||||
style: AppTextStyles.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: AppDimensions.spacingXl),
|
||||
|
||||
// 导航按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 上一题按钮
|
||||
if (!isFirst)
|
||||
OutlinedButton.icon(
|
||||
onPressed: onPrevious,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('上一题'),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
|
||||
// 下一题/完成按钮
|
||||
if (!isLast)
|
||||
ElevatedButton.icon(
|
||||
onPressed: selectedAnswer != null ? onNext : null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: const Text('下一题'),
|
||||
)
|
||||
else
|
||||
ElevatedButton.icon(
|
||||
onPressed: selectedAnswer != null ? onNext : null,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('完成'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getQuestionTypeLabel(QuestionType type) {
|
||||
switch (type) {
|
||||
case QuestionType.multipleChoice:
|
||||
return '选择题';
|
||||
case QuestionType.trueFalse:
|
||||
return '判断题';
|
||||
case QuestionType.fillInBlank:
|
||||
return '填空题';
|
||||
case QuestionType.shortAnswer:
|
||||
return '简答题';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getQuestionTypeColor(QuestionType type) {
|
||||
switch (type) {
|
||||
case QuestionType.multipleChoice:
|
||||
return AppColors.primary;
|
||||
case QuestionType.trueFalse:
|
||||
return AppColors.secondary;
|
||||
case QuestionType.fillInBlank:
|
||||
return AppColors.warning;
|
||||
case QuestionType.shortAnswer:
|
||||
return AppColors.info;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getOptionBackgroundColor(
|
||||
bool isSelected,
|
||||
bool isCorrect,
|
||||
bool isWrong,
|
||||
bool showResult,
|
||||
) {
|
||||
if (showResult) {
|
||||
if (isCorrect) return AppColors.success.withOpacity(0.1);
|
||||
if (isWrong) return AppColors.error.withOpacity(0.1);
|
||||
}
|
||||
if (isSelected) return AppColors.primary.withOpacity(0.1);
|
||||
return AppColors.surface;
|
||||
}
|
||||
|
||||
Color _getOptionBorderColor(
|
||||
bool isSelected,
|
||||
bool isCorrect,
|
||||
bool isWrong,
|
||||
bool showResult,
|
||||
) {
|
||||
if (showResult) {
|
||||
if (isCorrect) return AppColors.success;
|
||||
if (isWrong) return AppColors.error;
|
||||
}
|
||||
if (isSelected) return AppColors.primary;
|
||||
return AppColors.outline;
|
||||
}
|
||||
|
||||
Color _getOptionLabelColor(
|
||||
bool isSelected,
|
||||
bool isCorrect,
|
||||
bool isWrong,
|
||||
bool showResult,
|
||||
) {
|
||||
if (showResult) {
|
||||
if (isCorrect) return AppColors.success;
|
||||
if (isWrong) return AppColors.error;
|
||||
}
|
||||
if (isSelected) return AppColors.primary;
|
||||
return AppColors.surfaceVariant;
|
||||
}
|
||||
|
||||
Color _getOptionLabelTextColor(
|
||||
bool isSelected,
|
||||
bool isCorrect,
|
||||
bool isWrong,
|
||||
bool showResult,
|
||||
) {
|
||||
if (showResult && (isCorrect || isWrong)) return AppColors.onPrimary;
|
||||
if (isSelected) return AppColors.onPrimary;
|
||||
return AppColors.onSurfaceVariant;
|
||||
}
|
||||
|
||||
Color _getOptionTextColor(
|
||||
bool isSelected,
|
||||
bool isCorrect,
|
||||
bool isWrong,
|
||||
bool showResult,
|
||||
) {
|
||||
if (showResult) {
|
||||
if (isCorrect) return AppColors.success;
|
||||
if (isWrong) return AppColors.error;
|
||||
}
|
||||
if (isSelected) return AppColors.primary;
|
||||
return AppColors.onSurface;
|
||||
}
|
||||
}
|
||||
419
client/lib/features/reading/widgets/reading_result_dialog.dart
Normal file
419
client/lib/features/reading/widgets/reading_result_dialog.dart
Normal file
@@ -0,0 +1,419 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
import '../models/reading_question.dart';
|
||||
|
||||
/// 阅读结果对话框
|
||||
class ReadingResultDialog extends StatelessWidget {
|
||||
final ReadingExercise exercise;
|
||||
final VoidCallback? onReview;
|
||||
final VoidCallback? onRetry;
|
||||
final VoidCallback? onFinish;
|
||||
|
||||
const ReadingResultDialog({
|
||||
super.key,
|
||||
required this.exercise,
|
||||
this.onReview,
|
||||
this.onRetry,
|
||||
this.onFinish,
|
||||
});
|
||||
|
||||
ReadingExerciseResult get result {
|
||||
// 计算练习结果
|
||||
int correctCount = 0;
|
||||
int totalCount = exercise.questions.length;
|
||||
|
||||
for (final question in exercise.questions) {
|
||||
if (question.userAnswer == question.correctAnswer) {
|
||||
correctCount++;
|
||||
}
|
||||
}
|
||||
|
||||
double score = totalCount > 0 ? (correctCount / totalCount) * 100 : 0;
|
||||
|
||||
return ReadingExerciseResult(
|
||||
score: score,
|
||||
correctCount: correctCount,
|
||||
totalCount: totalCount,
|
||||
timeSpent: Duration.zero, // 可以从练习中获取
|
||||
accuracy: score,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusLg),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingLg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 结果图标和标题
|
||||
_buildHeader(),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 分数展示
|
||||
_buildScoreDisplay(),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 详细统计
|
||||
_buildDetailedStats(),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 评价和建议
|
||||
_buildFeedback(),
|
||||
const SizedBox(height: AppDimensions.spacingXl),
|
||||
|
||||
// 操作按钮
|
||||
_buildActionButtons(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final isExcellent = result.score >= 90;
|
||||
final isGood = result.score >= 70;
|
||||
final isPass = result.score >= 60;
|
||||
|
||||
IconData iconData;
|
||||
Color iconColor;
|
||||
String title;
|
||||
|
||||
if (isExcellent) {
|
||||
iconData = Icons.emoji_events;
|
||||
iconColor = AppColors.warning;
|
||||
title = '优秀!';
|
||||
} else if (isGood) {
|
||||
iconData = Icons.thumb_up;
|
||||
iconColor = AppColors.success;
|
||||
title = '良好!';
|
||||
} else if (isPass) {
|
||||
iconData = Icons.check_circle;
|
||||
iconColor = AppColors.info;
|
||||
title = '及格!';
|
||||
} else {
|
||||
iconData = Icons.refresh;
|
||||
iconColor = AppColors.error;
|
||||
title = '需要加油!';
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: iconColor.withOpacity(0.1),
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
size: 40,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingMd),
|
||||
Text(
|
||||
title,
|
||||
style: AppTextStyles.headlineSmall.copyWith(
|
||||
color: iconColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScoreDisplay() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingLg),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primary.withOpacity(0.1),
|
||||
AppColors.secondary.withOpacity(0.1),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'总分',
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
result.score.toString(),
|
||||
style: AppTextStyles.displayMedium.copyWith(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' / 100',
|
||||
style: AppTextStyles.titleLarge.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailedStats() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildStatRow('正确题数', '${result.correctCount}', AppColors.success),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
_buildStatRow('错误题数', '${result.wrongCount}', AppColors.error),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
_buildStatRow('总题数', '${result.totalQuestions}', AppColors.onSurface),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
_buildStatRow(
|
||||
'正确率',
|
||||
'${((result.correctCount / result.totalQuestions) * 100).toInt()}%',
|
||||
AppColors.primary,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
_buildStatRow(
|
||||
'用时',
|
||||
_formatDuration(result.timeSpent),
|
||||
AppColors.info,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatRow(String label, String value, Color valueColor) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTextStyles.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: valueColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeedback() {
|
||||
String feedback = _getFeedbackMessage();
|
||||
List<String> suggestions = _getSuggestions();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.info.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
border: Border.all(
|
||||
color: AppColors.info.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.psychology,
|
||||
color: AppColors.info,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'学习建议',
|
||||
style: AppTextStyles.titleSmall.copyWith(
|
||||
color: AppColors.info,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
Text(
|
||||
feedback,
|
||||
style: AppTextStyles.bodyMedium,
|
||||
),
|
||||
if (suggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
...suggestions.map((suggestion) => Padding(
|
||||
padding: const EdgeInsets.only(top: AppDimensions.spacingXs),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'• ',
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: AppColors.info,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: AppTextStyles.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// 查看解析按钮
|
||||
if (onReview != null)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onReview?.call();
|
||||
},
|
||||
icon: const Icon(Icons.visibility),
|
||||
label: const Text('查看解析'),
|
||||
),
|
||||
),
|
||||
|
||||
if (onReview != null && (onRetry != null || onFinish != null))
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
|
||||
// 重新练习按钮
|
||||
if (onRetry != null)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onRetry?.call();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重新练习'),
|
||||
),
|
||||
),
|
||||
|
||||
if (onRetry != null && onFinish != null)
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
|
||||
// 完成按钮
|
||||
if (onFinish != null)
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
onFinish?.call();
|
||||
},
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('完成'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getFeedbackMessage() {
|
||||
final accuracy = (result.correctCount / result.totalQuestions) * 100;
|
||||
|
||||
if (accuracy >= 90) {
|
||||
return '太棒了!你的阅读理解能力非常出色,继续保持这种学习状态!';
|
||||
} else if (accuracy >= 80) {
|
||||
return '很好!你的阅读理解能力不错,再接再厉!';
|
||||
} else if (accuracy >= 70) {
|
||||
return '不错!你的阅读理解能力还可以,继续努力提升!';
|
||||
} else if (accuracy >= 60) {
|
||||
return '及格了!但还有很大的提升空间,建议多练习阅读理解。';
|
||||
} else {
|
||||
return '需要加强练习!建议从基础阅读开始,逐步提升理解能力。';
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _getSuggestions() {
|
||||
final accuracy = (result.correctCount / result.totalQuestions) * 100;
|
||||
|
||||
if (accuracy >= 80) {
|
||||
return [
|
||||
'可以尝试更高难度的阅读材料',
|
||||
'注意总结阅读技巧和方法',
|
||||
'保持每日阅读的好习惯',
|
||||
];
|
||||
} else if (accuracy >= 60) {
|
||||
return [
|
||||
'多练习不同类型的阅读题目',
|
||||
'注意理解文章的主旨大意',
|
||||
'学会从文中寻找关键信息',
|
||||
'提高词汇量和语法理解',
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'从简单的阅读材料开始练习',
|
||||
'重点提升基础词汇量',
|
||||
'学习基本的阅读理解技巧',
|
||||
'每天坚持阅读练习',
|
||||
'可以寻求老师或同学的帮助',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
|
||||
if (minutes > 0) {
|
||||
return '${minutes}分${seconds}秒';
|
||||
} else {
|
||||
return '${seconds}秒';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示阅读结果对话框
|
||||
Future<void> showReadingResultDialog(
|
||||
BuildContext context,
|
||||
ReadingExercise exercise, {
|
||||
VoidCallback? onRestart,
|
||||
VoidCallback? onContinue,
|
||||
VoidCallback? onClose,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => ReadingResultDialog(
|
||||
exercise: exercise,
|
||||
onReview: onRestart,
|
||||
onRetry: onContinue,
|
||||
onFinish: onClose,
|
||||
),
|
||||
);
|
||||
}
|
||||
303
client/lib/features/reading/widgets/reading_search_bar.dart
Normal file
303
client/lib/features/reading/widgets/reading_search_bar.dart
Normal file
@@ -0,0 +1,303 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 阅读搜索栏组件
|
||||
class ReadingSearchBar extends StatefulWidget {
|
||||
final Function(String) onSearch;
|
||||
final String? initialQuery;
|
||||
final String hintText;
|
||||
|
||||
const ReadingSearchBar({
|
||||
super.key,
|
||||
required this.onSearch,
|
||||
this.initialQuery,
|
||||
this.hintText = '搜索文章标题、内容或标签...',
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingSearchBar> createState() => _ReadingSearchBarState();
|
||||
}
|
||||
|
||||
class _ReadingSearchBarState extends State<ReadingSearchBar> {
|
||||
late TextEditingController _controller;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialQuery);
|
||||
|
||||
// 自动聚焦
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _performSearch() {
|
||||
final query = _controller.text.trim();
|
||||
if (query.isNotEmpty) {
|
||||
widget.onSearch(query);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
const Text(
|
||||
'搜索文章',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 搜索输入框
|
||||
TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
hintStyle: TextStyle(color: Colors.grey[500]),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
suffixIcon: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey[300]!),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFF2196F3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey[300]!),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
},
|
||||
onSubmitted: (_) => _performSearch(),
|
||||
textInputAction: TextInputAction.search,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 搜索建议
|
||||
_buildSearchSuggestions(),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _controller.text.trim().isNotEmpty
|
||||
? _performSearch
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
child: const Text('搜索'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建搜索建议
|
||||
Widget _buildSearchSuggestions() {
|
||||
final suggestions = [
|
||||
'四级阅读',
|
||||
'六级阅读',
|
||||
'托福阅读',
|
||||
'雅思阅读',
|
||||
'商务英语',
|
||||
'日常对话',
|
||||
'科技文章',
|
||||
'新闻报道',
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'热门搜索',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: suggestions.map((suggestion) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_controller.text = suggestion;
|
||||
setState(() {});
|
||||
_performSearch();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 简化的搜索栏组件(用于嵌入页面)
|
||||
class ReadingSearchField extends StatefulWidget {
|
||||
final Function(String) onSearch;
|
||||
final Function()? onTap;
|
||||
final String? initialQuery;
|
||||
final bool enabled;
|
||||
|
||||
const ReadingSearchField({
|
||||
super.key,
|
||||
required this.onSearch,
|
||||
this.onTap,
|
||||
this.initialQuery,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingSearchField> createState() => _ReadingSearchFieldState();
|
||||
}
|
||||
|
||||
class _ReadingSearchFieldState extends State<ReadingSearchField> {
|
||||
late TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialQuery);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
enabled: widget.enabled,
|
||||
onTap: widget.onTap,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索文章...',
|
||||
hintStyle: TextStyle(color: Colors.grey[500]),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
suffixIcon: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
widget.onSearch('');
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
},
|
||||
onSubmitted: widget.onSearch,
|
||||
textInputAction: TextInputAction.search,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
289
client/lib/features/reading/widgets/reading_stats_card.dart
Normal file
289
client/lib/features/reading/widgets/reading_stats_card.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/reading_stats.dart';
|
||||
|
||||
/// 阅读统计卡片组件
|
||||
class ReadingStatsCard extends StatelessWidget {
|
||||
final ReadingStats stats;
|
||||
|
||||
const ReadingStatsCard({
|
||||
super.key,
|
||||
required this.stats,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF2196F3), Color(0xFF1976D2)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.analytics,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'阅读统计',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 统计数据网格
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.article,
|
||||
label: '已读文章',
|
||||
value: stats.totalArticlesRead.toString(),
|
||||
unit: '篇',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.quiz,
|
||||
label: '练习次数',
|
||||
value: stats.practicesDone.toString(),
|
||||
unit: '次',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.score,
|
||||
label: '平均分数',
|
||||
value: stats.averageScore.toStringAsFixed(1),
|
||||
unit: '分',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.speed,
|
||||
label: '阅读速度',
|
||||
value: stats.averageReadingSpeed.toStringAsFixed(0),
|
||||
unit: '词/分',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.schedule,
|
||||
label: '总时长',
|
||||
value: '${stats.totalReadingTime}分钟',
|
||||
unit: '',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.local_fire_department,
|
||||
label: '连续天数',
|
||||
value: stats.consecutiveDays.toString(),
|
||||
unit: '天',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 理解准确度进度条
|
||||
_buildAccuracyProgress(),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 词汇掌握进度条
|
||||
_buildVocabularyProgress(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建统计项目
|
||||
Widget _buildStatItem({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
required String unit,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.white70,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (unit.isNotEmpty)
|
||||
Text(
|
||||
unit,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建理解准确度进度条
|
||||
Widget _buildAccuracyProgress() {
|
||||
final accuracy = stats.comprehensionAccuracy;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'理解准确度',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${accuracy.toStringAsFixed(1)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: accuracy / 100,
|
||||
backgroundColor: Colors.white.withOpacity(0.3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_getAccuracyColor(accuracy),
|
||||
),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建词汇掌握进度条
|
||||
Widget _buildVocabularyProgress() {
|
||||
final vocabulary = stats.vocabularyMastered;
|
||||
final maxVocabulary = 10000; // 假设最大词汇量为10000
|
||||
final progress = (vocabulary / maxVocabulary).clamp(0.0, 1.0);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'词汇掌握',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$vocabulary 词',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: Colors.white.withOpacity(0.3),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
Colors.greenAccent,
|
||||
),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取准确度颜色
|
||||
Color _getAccuracyColor(double accuracy) {
|
||||
if (accuracy >= 90) {
|
||||
return Colors.greenAccent;
|
||||
} else if (accuracy >= 70) {
|
||||
return Colors.yellowAccent;
|
||||
} else {
|
||||
return Colors.redAccent;
|
||||
}
|
||||
}
|
||||
}
|
||||
340
client/lib/features/reading/widgets/reading_toolbar.dart
Normal file
340
client/lib/features/reading/widgets/reading_toolbar.dart
Normal file
@@ -0,0 +1,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
|
||||
/// 阅读工具栏组件
|
||||
class ReadingToolbar extends StatelessWidget {
|
||||
final VoidCallback? onBookmark;
|
||||
final VoidCallback? onShare;
|
||||
final VoidCallback? onTranslate;
|
||||
final VoidCallback? onHighlight;
|
||||
final VoidCallback? onNote;
|
||||
final VoidCallback? onSettings;
|
||||
final bool isBookmarked;
|
||||
final bool showProgress;
|
||||
final double? progress;
|
||||
|
||||
const ReadingToolbar({
|
||||
super.key,
|
||||
this.onBookmark,
|
||||
this.onShare,
|
||||
this.onTranslate,
|
||||
this.onHighlight,
|
||||
this.onNote,
|
||||
this.onSettings,
|
||||
this.isBookmarked = false,
|
||||
this.showProgress = false,
|
||||
this.progress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadow.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 进度条
|
||||
if (showProgress && progress != null)
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: AppColors.surfaceVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary),
|
||||
minHeight: 2,
|
||||
),
|
||||
|
||||
// 工具栏按钮
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// 书签
|
||||
_ToolbarButton(
|
||||
icon: isBookmarked ? Icons.bookmark : Icons.bookmark_border,
|
||||
label: '书签',
|
||||
onTap: onBookmark,
|
||||
isActive: isBookmarked,
|
||||
),
|
||||
|
||||
// 分享
|
||||
_ToolbarButton(
|
||||
icon: Icons.share_outlined,
|
||||
label: '分享',
|
||||
onTap: onShare,
|
||||
),
|
||||
|
||||
// 翻译
|
||||
_ToolbarButton(
|
||||
icon: Icons.translate_outlined,
|
||||
label: '翻译',
|
||||
onTap: onTranslate,
|
||||
),
|
||||
|
||||
// 高亮
|
||||
_ToolbarButton(
|
||||
icon: Icons.highlight_outlined,
|
||||
label: '高亮',
|
||||
onTap: onHighlight,
|
||||
),
|
||||
|
||||
// 笔记
|
||||
_ToolbarButton(
|
||||
icon: Icons.note_outlined,
|
||||
label: '笔记',
|
||||
onTap: onNote,
|
||||
),
|
||||
|
||||
// 设置
|
||||
_ToolbarButton(
|
||||
icon: Icons.settings_outlined,
|
||||
label: '设置',
|
||||
onTap: onSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolbarButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final bool isActive;
|
||||
|
||||
const _ToolbarButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusSm),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.spacingXs,
|
||||
vertical: AppDimensions.spacingXs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isActive ? AppColors.primary : AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: isActive ? AppColors.primary : AppColors.onSurfaceVariant,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读设置底部弹窗
|
||||
class ReadingSettingsBottomSheet extends StatefulWidget {
|
||||
final double fontSize;
|
||||
final double lineHeight;
|
||||
final bool isDarkMode;
|
||||
final Function(double)? onFontSizeChanged;
|
||||
final Function(double)? onLineHeightChanged;
|
||||
final Function(bool)? onDarkModeChanged;
|
||||
|
||||
const ReadingSettingsBottomSheet({
|
||||
super.key,
|
||||
required this.fontSize,
|
||||
required this.lineHeight,
|
||||
required this.isDarkMode,
|
||||
this.onFontSizeChanged,
|
||||
this.onLineHeightChanged,
|
||||
this.onDarkModeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingSettingsBottomSheet> createState() => _ReadingSettingsBottomSheetState();
|
||||
}
|
||||
|
||||
class _ReadingSettingsBottomSheetState extends State<ReadingSettingsBottomSheet> {
|
||||
late double _fontSize;
|
||||
late double _lineHeight;
|
||||
late bool _isDarkMode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fontSize = widget.fontSize;
|
||||
_lineHeight = widget.lineHeight;
|
||||
_isDarkMode = widget.isDarkMode;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingLg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppDimensions.radiusLg),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'阅读设置',
|
||||
style: AppTextStyles.headlineSmall,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 字体大小
|
||||
Text(
|
||||
'字体大小',
|
||||
style: AppTextStyles.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (_fontSize > 12) {
|
||||
setState(() {
|
||||
_fontSize -= 1;
|
||||
});
|
||||
widget.onFontSizeChanged?.call(_fontSize);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _fontSize,
|
||||
min: 12,
|
||||
max: 24,
|
||||
divisions: 12,
|
||||
label: '${_fontSize.toInt()}',
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_fontSize = value;
|
||||
});
|
||||
widget.onFontSizeChanged?.call(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (_fontSize < 24) {
|
||||
setState(() {
|
||||
_fontSize += 1;
|
||||
});
|
||||
widget.onFontSizeChanged?.call(_fontSize);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 行间距
|
||||
Text(
|
||||
'行间距',
|
||||
style: AppTextStyles.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (_lineHeight > 1.2) {
|
||||
setState(() {
|
||||
_lineHeight -= 0.1;
|
||||
});
|
||||
widget.onLineHeightChanged?.call(_lineHeight);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _lineHeight,
|
||||
min: 1.2,
|
||||
max: 2.0,
|
||||
divisions: 8,
|
||||
label: '${(_lineHeight * 10).toInt() / 10}',
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_lineHeight = value;
|
||||
});
|
||||
widget.onLineHeightChanged?.call(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (_lineHeight < 2.0) {
|
||||
setState(() {
|
||||
_lineHeight += 0.1;
|
||||
});
|
||||
widget.onLineHeightChanged?.call(_lineHeight);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 夜间模式
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
'夜间模式',
|
||||
style: AppTextStyles.titleMedium,
|
||||
),
|
||||
value: _isDarkMode,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isDarkMode = value;
|
||||
});
|
||||
widget.onDarkModeChanged?.call(value);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user