This commit is contained in:
sjk
2025-11-17 13:39:05 +08:00
commit d4cfe2b9de
479 changed files with 109324 additions and 0 deletions

View 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,
);
}
}

View 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),
),
);
}
}

View 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;
}
}
}

View File

@@ -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,
),
),
);
},
),
),
);
},
),
),
],
);
}
}

View 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 '未知';
}
}
}

View 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('取消'),
),
],
);
},
);
}
}

View 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}';
}
}
}

View 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,
),
),
);
},
),
);
},
),
),
],
);
}
}