init
This commit is contained in:
274
client/lib/features/reading/widgets/reading_article_card.dart
Normal file
274
client/lib/features/reading/widgets/reading_article_card.dart
Normal file
@@ -0,0 +1,274 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/reading_article.dart';
|
||||
|
||||
/// 阅读文章卡片组件
|
||||
class ReadingArticleCard extends StatelessWidget {
|
||||
final ReadingArticle article;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onFavorite;
|
||||
final bool showProgress;
|
||||
|
||||
const ReadingArticleCard({
|
||||
super.key,
|
||||
required this.article,
|
||||
this.onTap,
|
||||
this.onFavorite,
|
||||
this.showProgress = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题和收藏按钮
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
article.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (onFavorite != null)
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.favorite_border,
|
||||
color: Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: onFavorite,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 24,
|
||||
minHeight: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 文章摘要
|
||||
if (article.content.isNotEmpty)
|
||||
Text(
|
||||
_getExcerpt(article.content),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
height: 1.4,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 标签行
|
||||
Row(
|
||||
children: [
|
||||
// 分类标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
article.categoryLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF2196F3),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 难度标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDifficultyColor(article.difficulty).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
article.difficultyLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getDifficultyColor(article.difficulty),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// 字数和阅读时间
|
||||
Text(
|
||||
'${article.wordCount}词',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${article.readingTime}分钟',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 进度条(如果需要显示)
|
||||
if (showProgress && article.isCompleted)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'已完成',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (article.comprehensionScore != null)
|
||||
Text(
|
||||
'得分: ${article.comprehensionScore}分',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getScoreColor(article.comprehensionScore!.toInt()),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: 1.0,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_getScoreColor((article.comprehensionScore ?? 0).toInt()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 标签(如果有)
|
||||
if (article.tags.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: article.tags.take(3).map((tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'#$tag',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取文章摘要
|
||||
String _getExcerpt(String content) {
|
||||
// 移除HTML标签和多余空白
|
||||
String cleanContent = content
|
||||
.replaceAll(RegExp(r'<[^>]*>'), '')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
// 截取前100个字符作为摘要
|
||||
if (cleanContent.length <= 100) {
|
||||
return cleanContent;
|
||||
}
|
||||
|
||||
return '${cleanContent.substring(0, 100)}...';
|
||||
}
|
||||
|
||||
/// 获取难度颜色
|
||||
Color _getDifficultyColor(String difficulty) {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'a1':
|
||||
case 'a2':
|
||||
return Colors.green;
|
||||
case 'b1':
|
||||
case 'b2':
|
||||
return Colors.orange;
|
||||
case 'c1':
|
||||
case 'c2':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取分数颜色
|
||||
Color _getScoreColor(int score) {
|
||||
if (score >= 90) {
|
||||
return Colors.green;
|
||||
} else if (score >= 70) {
|
||||
return Colors.orange;
|
||||
} else {
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
}
|
||||
240
client/lib/features/reading/widgets/reading_category_tabs.dart
Normal file
240
client/lib/features/reading/widgets/reading_category_tabs.dart
Normal file
@@ -0,0 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/reading_provider.dart';
|
||||
|
||||
/// 阅读分类标签组件
|
||||
class ReadingCategoryTabs extends StatelessWidget {
|
||||
const ReadingCategoryTabs({super.key});
|
||||
|
||||
static const List<Map<String, String>> categories = [
|
||||
{'key': '', 'label': '全部'},
|
||||
{'key': 'cet4', 'label': '四级'},
|
||||
{'key': 'cet6', 'label': '六级'},
|
||||
{'key': 'toefl', 'label': '托福'},
|
||||
{'key': 'ielts', 'label': '雅思'},
|
||||
{'key': 'daily', 'label': '日常'},
|
||||
{'key': 'business', 'label': '商务'},
|
||||
{'key': 'academic', 'label': '学术'},
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 50,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
),
|
||||
child: Consumer<ReadingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
final isSelected = provider.selectedCategory == category['key'] ||
|
||||
(provider.selectedCategory == null && category['key'] == '');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _buildCategoryChip(
|
||||
context,
|
||||
category['label']!,
|
||||
category['key']!,
|
||||
isSelected,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建分类标签
|
||||
Widget _buildCategoryChip(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String key,
|
||||
bool isSelected,
|
||||
ReadingProvider provider,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final selectedKey = key.isEmpty ? null : key;
|
||||
provider.setFilter(category: selectedKey);
|
||||
provider.loadArticles(refresh: true);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFF2196F3)
|
||||
: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF2196F3)
|
||||
: Colors.grey[300]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: Colors.grey[700],
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读难度筛选组件
|
||||
class ReadingDifficultyFilter extends StatelessWidget {
|
||||
const ReadingDifficultyFilter({super.key});
|
||||
|
||||
static const List<Map<String, String>> difficulties = [
|
||||
{'key': '', 'label': '全部难度'},
|
||||
{'key': 'a1', 'label': 'A1'},
|
||||
{'key': 'a2', 'label': 'A2'},
|
||||
{'key': 'b1', 'label': 'B1'},
|
||||
{'key': 'b2', 'label': 'B2'},
|
||||
{'key': 'c1', 'label': 'C1'},
|
||||
{'key': 'c2', 'label': 'C2'},
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 50,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
),
|
||||
child: Consumer<ReadingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: difficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
final difficulty = difficulties[index];
|
||||
final isSelected = provider.selectedDifficulty == difficulty['key'] ||
|
||||
(provider.selectedDifficulty == null && difficulty['key'] == '');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _buildDifficultyChip(
|
||||
context,
|
||||
difficulty['label']!,
|
||||
difficulty['key']!,
|
||||
isSelected,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建难度标签
|
||||
Widget _buildDifficultyChip(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String key,
|
||||
bool isSelected,
|
||||
ReadingProvider provider,
|
||||
) {
|
||||
final color = _getDifficultyColor(key);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final selectedKey = key.isEmpty ? null : key;
|
||||
provider.setFilter(difficulty: selectedKey);
|
||||
provider.loadArticles(refresh: true);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? color
|
||||
: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: color,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: color,
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取难度颜色
|
||||
Color _getDifficultyColor(String difficulty) {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'a1':
|
||||
case 'a2':
|
||||
return Colors.green;
|
||||
case 'b1':
|
||||
case 'b2':
|
||||
return Colors.orange;
|
||||
case 'c1':
|
||||
case 'c2':
|
||||
return Colors.red;
|
||||
default:
|
||||
return const Color(0xFF2196F3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 组合的分类和难度筛选组件
|
||||
class ReadingFilterTabs extends StatelessWidget {
|
||||
const ReadingFilterTabs({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const ReadingCategoryTabs(),
|
||||
const ReadingDifficultyFilter(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
209
client/lib/features/reading/widgets/reading_content_widget.dart
Normal file
209
client/lib/features/reading/widgets/reading_content_widget.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/reading_article.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
|
||||
/// 阅读内容组件
|
||||
class ReadingContentWidget extends StatefulWidget {
|
||||
final ReadingArticle article;
|
||||
final ScrollController? scrollController;
|
||||
final VoidCallback? onWordTap;
|
||||
final Function(String)? onTextSelection;
|
||||
|
||||
const ReadingContentWidget({
|
||||
super.key,
|
||||
required this.article,
|
||||
this.scrollController,
|
||||
this.onWordTap,
|
||||
this.onTextSelection,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingContentWidget> createState() => _ReadingContentWidgetState();
|
||||
}
|
||||
|
||||
class _ReadingContentWidgetState extends State<ReadingContentWidget> {
|
||||
double _fontSize = 16.0;
|
||||
double _lineHeight = 1.6;
|
||||
bool _isDarkMode = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 文章标题
|
||||
Text(
|
||||
widget.article.title,
|
||||
style: AppTextStyles.headlineMedium.copyWith(
|
||||
fontSize: _fontSize + 4,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _isDarkMode ? AppColors.onSurface : AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingMd),
|
||||
|
||||
// 文章信息
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category_outlined,
|
||||
size: 16,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
widget.article.category,
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
Icon(
|
||||
Icons.schedule_outlined,
|
||||
size: 16,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'${widget.article.estimatedReadingTime} 分钟',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
Icon(
|
||||
Icons.text_fields_outlined,
|
||||
size: 16,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'${widget.article.wordCount} 词',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 阅读设置工具栏
|
||||
_buildReadingToolbar(),
|
||||
const SizedBox(height: AppDimensions.spacingMd),
|
||||
|
||||
// 文章内容
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: widget.scrollController,
|
||||
child: SelectableText(
|
||||
widget.article.content,
|
||||
style: AppTextStyles.bodyLarge.copyWith(
|
||||
fontSize: _fontSize,
|
||||
height: _lineHeight,
|
||||
color: _isDarkMode ? AppColors.onSurface : AppColors.onSurface,
|
||||
),
|
||||
onSelectionChanged: (selection, cause) {
|
||||
if (selection.isValid && widget.onTextSelection != null) {
|
||||
final selectedText = widget.article.content
|
||||
.substring(selection.start, selection.end);
|
||||
widget.onTextSelection!(selectedText);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadingToolbar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.spacingSm,
|
||||
vertical: AppDimensions.spacingXs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// 字体大小调节
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_fontSize > 12) _fontSize -= 1;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.text_decrease),
|
||||
iconSize: 20,
|
||||
),
|
||||
Text(
|
||||
'${_fontSize.toInt()}',
|
||||
style: AppTextStyles.bodySmall,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_fontSize < 24) _fontSize += 1;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.text_increase),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 行间距调节
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_lineHeight > 1.2) _lineHeight -= 0.1;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.format_line_spacing),
|
||||
iconSize: 20,
|
||||
),
|
||||
Text(
|
||||
'${(_lineHeight * 10).toInt() / 10}',
|
||||
style: AppTextStyles.bodySmall,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_lineHeight < 2.0) _lineHeight += 0.1;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.format_line_spacing),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 夜间模式切换
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isDarkMode = !_isDarkMode;
|
||||
});
|
||||
},
|
||||
icon: Icon(
|
||||
_isDarkMode ? Icons.light_mode : Icons.dark_mode,
|
||||
),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
240
client/lib/features/reading/widgets/reading_progress_bar.dart
Normal file
240
client/lib/features/reading/widgets/reading_progress_bar.dart
Normal file
@@ -0,0 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
|
||||
/// 阅读进度条组件
|
||||
class ReadingProgressBar extends StatelessWidget {
|
||||
final int current;
|
||||
final int total;
|
||||
final double? progress;
|
||||
final String? label;
|
||||
final bool showPercentage;
|
||||
final Color? progressColor;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const ReadingProgressBar({
|
||||
super.key,
|
||||
required this.current,
|
||||
required this.total,
|
||||
this.progress,
|
||||
this.label,
|
||||
this.showPercentage = true,
|
||||
this.progressColor,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progressValue = progress ?? (total > 0 ? current / total : 0.0);
|
||||
final percentage = (progressValue * 100).toInt();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题和进度信息
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label ?? '进度',
|
||||
style: AppTextStyles.titleMedium,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'$current / $total',
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (showPercentage) ...[
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'$percentage%',
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: progressColor ?? AppColors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
|
||||
// 进度条
|
||||
LinearProgressIndicator(
|
||||
value: progressValue,
|
||||
backgroundColor: backgroundColor ?? AppColors.surfaceVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
progressColor ?? AppColors.primary,
|
||||
),
|
||||
minHeight: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 圆形进度条组件
|
||||
class ReadingCircularProgressBar extends StatelessWidget {
|
||||
final int current;
|
||||
final int total;
|
||||
final double? progress;
|
||||
final double size;
|
||||
final double strokeWidth;
|
||||
final Color? progressColor;
|
||||
final Color? backgroundColor;
|
||||
final Widget? child;
|
||||
|
||||
const ReadingCircularProgressBar({
|
||||
super.key,
|
||||
required this.current,
|
||||
required this.total,
|
||||
this.progress,
|
||||
this.size = 80,
|
||||
this.strokeWidth = 6,
|
||||
this.progressColor,
|
||||
this.backgroundColor,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progressValue = progress ?? (total > 0 ? current / total : 0.0);
|
||||
final percentage = (progressValue * 100).toInt();
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 圆形进度条
|
||||
CircularProgressIndicator(
|
||||
value: progressValue,
|
||||
strokeWidth: strokeWidth,
|
||||
backgroundColor: backgroundColor ?? AppColors.surfaceVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
progressColor ?? AppColors.primary,
|
||||
),
|
||||
),
|
||||
|
||||
// 中心内容
|
||||
child ??
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$percentage%',
|
||||
style: AppTextStyles.titleMedium.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: progressColor ?? AppColors.primary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$current/$total',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 步骤进度条组件
|
||||
class ReadingStepProgressBar extends StatelessWidget {
|
||||
final int currentStep;
|
||||
final int totalSteps;
|
||||
final List<String>? stepLabels;
|
||||
final Color? activeColor;
|
||||
final Color? inactiveColor;
|
||||
final Color? completedColor;
|
||||
|
||||
const ReadingStepProgressBar({
|
||||
super.key,
|
||||
required this.currentStep,
|
||||
required this.totalSteps,
|
||||
this.stepLabels,
|
||||
this.activeColor,
|
||||
this.inactiveColor,
|
||||
this.completedColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
child: Row(
|
||||
children: List.generate(totalSteps, (index) {
|
||||
final stepNumber = index + 1;
|
||||
final isCompleted = stepNumber < currentStep;
|
||||
final isActive = stepNumber == currentStep;
|
||||
final isInactive = stepNumber > currentStep;
|
||||
|
||||
Color stepColor;
|
||||
if (isCompleted) {
|
||||
stepColor = completedColor ?? AppColors.success;
|
||||
} else if (isActive) {
|
||||
stepColor = activeColor ?? AppColors.primary;
|
||||
} else {
|
||||
stepColor = inactiveColor ?? AppColors.surfaceVariant;
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// 步骤圆圈
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: stepColor,
|
||||
),
|
||||
child: Center(
|
||||
child: isCompleted
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: AppColors.onPrimary,
|
||||
size: 16,
|
||||
)
|
||||
: Text(
|
||||
stepNumber.toString(),
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: isInactive
|
||||
? AppColors.onSurfaceVariant
|
||||
: AppColors.onPrimary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 连接线(除了最后一个步骤)
|
||||
if (index < totalSteps - 1)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 2,
|
||||
color: isCompleted
|
||||
? (completedColor ?? AppColors.success)
|
||||
: (inactiveColor ?? AppColors.surfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
335
client/lib/features/reading/widgets/reading_question_widget.dart
Normal file
335
client/lib/features/reading/widgets/reading_question_widget.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
import '../models/reading_question.dart';
|
||||
|
||||
/// 阅读问题组件
|
||||
class ReadingQuestionWidget extends StatelessWidget {
|
||||
final ReadingQuestion question;
|
||||
final String? selectedAnswer;
|
||||
final bool showResult;
|
||||
final Function(String) onAnswerSelected;
|
||||
final VoidCallback? onNext;
|
||||
final VoidCallback? onPrevious;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
|
||||
const ReadingQuestionWidget({
|
||||
super.key,
|
||||
required this.question,
|
||||
this.selectedAnswer,
|
||||
this.showResult = false,
|
||||
required this.onAnswerSelected,
|
||||
this.onNext,
|
||||
this.onPrevious,
|
||||
this.isFirst = false,
|
||||
this.isLast = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingLg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 问题类型标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.spacingSm,
|
||||
vertical: AppDimensions.spacingXs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getQuestionTypeColor(question.type).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusSm),
|
||||
border: Border.all(
|
||||
color: _getQuestionTypeColor(question.type),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_getQuestionTypeLabel(question.type),
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: _getQuestionTypeColor(question.type),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingMd),
|
||||
|
||||
// 问题内容
|
||||
Text(
|
||||
question.question,
|
||||
style: AppTextStyles.titleMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 选项列表
|
||||
...question.options.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final option = entry.value;
|
||||
final optionKey = String.fromCharCode(65 + index.toInt()); // A, B, C, D
|
||||
final isSelected = selectedAnswer == optionKey;
|
||||
final isCorrect = showResult && question.correctAnswer == optionKey;
|
||||
final isWrong = showResult && isSelected && !isCorrect;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppDimensions.spacingSm),
|
||||
child: InkWell(
|
||||
onTap: showResult ? null : () => onAnswerSelected(optionKey),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: _getOptionBackgroundColor(
|
||||
isSelected,
|
||||
isCorrect,
|
||||
isWrong,
|
||||
showResult,
|
||||
),
|
||||
border: Border.all(
|
||||
color: _getOptionBorderColor(
|
||||
isSelected,
|
||||
isCorrect,
|
||||
isWrong,
|
||||
showResult,
|
||||
),
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 选项标识
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _getOptionLabelColor(
|
||||
isSelected,
|
||||
isCorrect,
|
||||
isWrong,
|
||||
showResult,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
optionKey,
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: _getOptionLabelTextColor(
|
||||
isSelected,
|
||||
isCorrect,
|
||||
isWrong,
|
||||
showResult,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
|
||||
// 选项内容
|
||||
Expanded(
|
||||
child: Text(
|
||||
option,
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: _getOptionTextColor(
|
||||
isSelected,
|
||||
isCorrect,
|
||||
isWrong,
|
||||
showResult,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 结果图标
|
||||
if (showResult && (isCorrect || isWrong))
|
||||
Icon(
|
||||
isCorrect ? Icons.check_circle : Icons.cancel,
|
||||
color: isCorrect ? AppColors.success : AppColors.error,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
// 解析(如果显示结果且有解析)
|
||||
if (showResult && question.explanation != null) ...[
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.info.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
border: Border.all(
|
||||
color: AppColors.info.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
color: AppColors.info,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'解析',
|
||||
style: AppTextStyles.titleSmall.copyWith(
|
||||
color: AppColors.info,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
Text(
|
||||
question.explanation!,
|
||||
style: AppTextStyles.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: AppDimensions.spacingXl),
|
||||
|
||||
// 导航按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 上一题按钮
|
||||
if (!isFirst)
|
||||
OutlinedButton.icon(
|
||||
onPressed: onPrevious,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('上一题'),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
|
||||
// 下一题/完成按钮
|
||||
if (!isLast)
|
||||
ElevatedButton.icon(
|
||||
onPressed: selectedAnswer != null ? onNext : null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: const Text('下一题'),
|
||||
)
|
||||
else
|
||||
ElevatedButton.icon(
|
||||
onPressed: selectedAnswer != null ? onNext : null,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('完成'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getQuestionTypeLabel(QuestionType type) {
|
||||
switch (type) {
|
||||
case QuestionType.multipleChoice:
|
||||
return '选择题';
|
||||
case QuestionType.trueFalse:
|
||||
return '判断题';
|
||||
case QuestionType.fillInBlank:
|
||||
return '填空题';
|
||||
case QuestionType.shortAnswer:
|
||||
return '简答题';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getQuestionTypeColor(QuestionType type) {
|
||||
switch (type) {
|
||||
case QuestionType.multipleChoice:
|
||||
return AppColors.primary;
|
||||
case QuestionType.trueFalse:
|
||||
return AppColors.secondary;
|
||||
case QuestionType.fillInBlank:
|
||||
return AppColors.warning;
|
||||
case QuestionType.shortAnswer:
|
||||
return AppColors.info;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getOptionBackgroundColor(
|
||||
bool isSelected,
|
||||
bool isCorrect,
|
||||
bool isWrong,
|
||||
bool showResult,
|
||||
) {
|
||||
if (showResult) {
|
||||
if (isCorrect) return AppColors.success.withOpacity(0.1);
|
||||
if (isWrong) return AppColors.error.withOpacity(0.1);
|
||||
}
|
||||
if (isSelected) return AppColors.primary.withOpacity(0.1);
|
||||
return AppColors.surface;
|
||||
}
|
||||
|
||||
Color _getOptionBorderColor(
|
||||
bool isSelected,
|
||||
bool isCorrect,
|
||||
bool isWrong,
|
||||
bool showResult,
|
||||
) {
|
||||
if (showResult) {
|
||||
if (isCorrect) return AppColors.success;
|
||||
if (isWrong) return AppColors.error;
|
||||
}
|
||||
if (isSelected) return AppColors.primary;
|
||||
return AppColors.outline;
|
||||
}
|
||||
|
||||
Color _getOptionLabelColor(
|
||||
bool isSelected,
|
||||
bool isCorrect,
|
||||
bool isWrong,
|
||||
bool showResult,
|
||||
) {
|
||||
if (showResult) {
|
||||
if (isCorrect) return AppColors.success;
|
||||
if (isWrong) return AppColors.error;
|
||||
}
|
||||
if (isSelected) return AppColors.primary;
|
||||
return AppColors.surfaceVariant;
|
||||
}
|
||||
|
||||
Color _getOptionLabelTextColor(
|
||||
bool isSelected,
|
||||
bool isCorrect,
|
||||
bool isWrong,
|
||||
bool showResult,
|
||||
) {
|
||||
if (showResult && (isCorrect || isWrong)) return AppColors.onPrimary;
|
||||
if (isSelected) return AppColors.onPrimary;
|
||||
return AppColors.onSurfaceVariant;
|
||||
}
|
||||
|
||||
Color _getOptionTextColor(
|
||||
bool isSelected,
|
||||
bool isCorrect,
|
||||
bool isWrong,
|
||||
bool showResult,
|
||||
) {
|
||||
if (showResult) {
|
||||
if (isCorrect) return AppColors.success;
|
||||
if (isWrong) return AppColors.error;
|
||||
}
|
||||
if (isSelected) return AppColors.primary;
|
||||
return AppColors.onSurface;
|
||||
}
|
||||
}
|
||||
419
client/lib/features/reading/widgets/reading_result_dialog.dart
Normal file
419
client/lib/features/reading/widgets/reading_result_dialog.dart
Normal file
@@ -0,0 +1,419 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
import '../models/reading_question.dart';
|
||||
|
||||
/// 阅读结果对话框
|
||||
class ReadingResultDialog extends StatelessWidget {
|
||||
final ReadingExercise exercise;
|
||||
final VoidCallback? onReview;
|
||||
final VoidCallback? onRetry;
|
||||
final VoidCallback? onFinish;
|
||||
|
||||
const ReadingResultDialog({
|
||||
super.key,
|
||||
required this.exercise,
|
||||
this.onReview,
|
||||
this.onRetry,
|
||||
this.onFinish,
|
||||
});
|
||||
|
||||
ReadingExerciseResult get result {
|
||||
// 计算练习结果
|
||||
int correctCount = 0;
|
||||
int totalCount = exercise.questions.length;
|
||||
|
||||
for (final question in exercise.questions) {
|
||||
if (question.userAnswer == question.correctAnswer) {
|
||||
correctCount++;
|
||||
}
|
||||
}
|
||||
|
||||
double score = totalCount > 0 ? (correctCount / totalCount) * 100 : 0;
|
||||
|
||||
return ReadingExerciseResult(
|
||||
score: score,
|
||||
correctCount: correctCount,
|
||||
totalCount: totalCount,
|
||||
timeSpent: Duration.zero, // 可以从练习中获取
|
||||
accuracy: score,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusLg),
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingLg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 结果图标和标题
|
||||
_buildHeader(),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 分数展示
|
||||
_buildScoreDisplay(),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 详细统计
|
||||
_buildDetailedStats(),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 评价和建议
|
||||
_buildFeedback(),
|
||||
const SizedBox(height: AppDimensions.spacingXl),
|
||||
|
||||
// 操作按钮
|
||||
_buildActionButtons(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final isExcellent = result.score >= 90;
|
||||
final isGood = result.score >= 70;
|
||||
final isPass = result.score >= 60;
|
||||
|
||||
IconData iconData;
|
||||
Color iconColor;
|
||||
String title;
|
||||
|
||||
if (isExcellent) {
|
||||
iconData = Icons.emoji_events;
|
||||
iconColor = AppColors.warning;
|
||||
title = '优秀!';
|
||||
} else if (isGood) {
|
||||
iconData = Icons.thumb_up;
|
||||
iconColor = AppColors.success;
|
||||
title = '良好!';
|
||||
} else if (isPass) {
|
||||
iconData = Icons.check_circle;
|
||||
iconColor = AppColors.info;
|
||||
title = '及格!';
|
||||
} else {
|
||||
iconData = Icons.refresh;
|
||||
iconColor = AppColors.error;
|
||||
title = '需要加油!';
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: iconColor.withOpacity(0.1),
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
size: 40,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingMd),
|
||||
Text(
|
||||
title,
|
||||
style: AppTextStyles.headlineSmall.copyWith(
|
||||
color: iconColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScoreDisplay() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingLg),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.primary.withOpacity(0.1),
|
||||
AppColors.secondary.withOpacity(0.1),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'总分',
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
result.score.toString(),
|
||||
style: AppTextStyles.displayMedium.copyWith(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' / 100',
|
||||
style: AppTextStyles.titleLarge.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailedStats() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildStatRow('正确题数', '${result.correctCount}', AppColors.success),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
_buildStatRow('错误题数', '${result.wrongCount}', AppColors.error),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
_buildStatRow('总题数', '${result.totalQuestions}', AppColors.onSurface),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
_buildStatRow(
|
||||
'正确率',
|
||||
'${((result.correctCount / result.totalQuestions) * 100).toInt()}%',
|
||||
AppColors.primary,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
_buildStatRow(
|
||||
'用时',
|
||||
_formatDuration(result.timeSpent),
|
||||
AppColors.info,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatRow(String label, String value, Color valueColor) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTextStyles.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: valueColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeedback() {
|
||||
String feedback = _getFeedbackMessage();
|
||||
List<String> suggestions = _getSuggestions();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.info.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusMd),
|
||||
border: Border.all(
|
||||
color: AppColors.info.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.psychology,
|
||||
color: AppColors.info,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: AppDimensions.spacingSm),
|
||||
Text(
|
||||
'学习建议',
|
||||
style: AppTextStyles.titleSmall.copyWith(
|
||||
color: AppColors.info,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
Text(
|
||||
feedback,
|
||||
style: AppTextStyles.bodyMedium,
|
||||
),
|
||||
if (suggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
...suggestions.map((suggestion) => Padding(
|
||||
padding: const EdgeInsets.only(top: AppDimensions.spacingXs),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'• ',
|
||||
style: AppTextStyles.bodyMedium.copyWith(
|
||||
color: AppColors.info,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: AppTextStyles.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// 查看解析按钮
|
||||
if (onReview != null)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onReview?.call();
|
||||
},
|
||||
icon: const Icon(Icons.visibility),
|
||||
label: const Text('查看解析'),
|
||||
),
|
||||
),
|
||||
|
||||
if (onReview != null && (onRetry != null || onFinish != null))
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
|
||||
// 重新练习按钮
|
||||
if (onRetry != null)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onRetry?.call();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重新练习'),
|
||||
),
|
||||
),
|
||||
|
||||
if (onRetry != null && onFinish != null)
|
||||
const SizedBox(width: AppDimensions.spacingMd),
|
||||
|
||||
// 完成按钮
|
||||
if (onFinish != null)
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
onFinish?.call();
|
||||
},
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('完成'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getFeedbackMessage() {
|
||||
final accuracy = (result.correctCount / result.totalQuestions) * 100;
|
||||
|
||||
if (accuracy >= 90) {
|
||||
return '太棒了!你的阅读理解能力非常出色,继续保持这种学习状态!';
|
||||
} else if (accuracy >= 80) {
|
||||
return '很好!你的阅读理解能力不错,再接再厉!';
|
||||
} else if (accuracy >= 70) {
|
||||
return '不错!你的阅读理解能力还可以,继续努力提升!';
|
||||
} else if (accuracy >= 60) {
|
||||
return '及格了!但还有很大的提升空间,建议多练习阅读理解。';
|
||||
} else {
|
||||
return '需要加强练习!建议从基础阅读开始,逐步提升理解能力。';
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _getSuggestions() {
|
||||
final accuracy = (result.correctCount / result.totalQuestions) * 100;
|
||||
|
||||
if (accuracy >= 80) {
|
||||
return [
|
||||
'可以尝试更高难度的阅读材料',
|
||||
'注意总结阅读技巧和方法',
|
||||
'保持每日阅读的好习惯',
|
||||
];
|
||||
} else if (accuracy >= 60) {
|
||||
return [
|
||||
'多练习不同类型的阅读题目',
|
||||
'注意理解文章的主旨大意',
|
||||
'学会从文中寻找关键信息',
|
||||
'提高词汇量和语法理解',
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'从简单的阅读材料开始练习',
|
||||
'重点提升基础词汇量',
|
||||
'学习基本的阅读理解技巧',
|
||||
'每天坚持阅读练习',
|
||||
'可以寻求老师或同学的帮助',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
|
||||
if (minutes > 0) {
|
||||
return '${minutes}分${seconds}秒';
|
||||
} else {
|
||||
return '${seconds}秒';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示阅读结果对话框
|
||||
Future<void> showReadingResultDialog(
|
||||
BuildContext context,
|
||||
ReadingExercise exercise, {
|
||||
VoidCallback? onRestart,
|
||||
VoidCallback? onContinue,
|
||||
VoidCallback? onClose,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => ReadingResultDialog(
|
||||
exercise: exercise,
|
||||
onReview: onRestart,
|
||||
onRetry: onContinue,
|
||||
onFinish: onClose,
|
||||
),
|
||||
);
|
||||
}
|
||||
303
client/lib/features/reading/widgets/reading_search_bar.dart
Normal file
303
client/lib/features/reading/widgets/reading_search_bar.dart
Normal file
@@ -0,0 +1,303 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 阅读搜索栏组件
|
||||
class ReadingSearchBar extends StatefulWidget {
|
||||
final Function(String) onSearch;
|
||||
final String? initialQuery;
|
||||
final String hintText;
|
||||
|
||||
const ReadingSearchBar({
|
||||
super.key,
|
||||
required this.onSearch,
|
||||
this.initialQuery,
|
||||
this.hintText = '搜索文章标题、内容或标签...',
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingSearchBar> createState() => _ReadingSearchBarState();
|
||||
}
|
||||
|
||||
class _ReadingSearchBarState extends State<ReadingSearchBar> {
|
||||
late TextEditingController _controller;
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialQuery);
|
||||
|
||||
// 自动聚焦
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _performSearch() {
|
||||
final query = _controller.text.trim();
|
||||
if (query.isNotEmpty) {
|
||||
widget.onSearch(query);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
const Text(
|
||||
'搜索文章',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 搜索输入框
|
||||
TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
hintStyle: TextStyle(color: Colors.grey[500]),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
suffixIcon: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey[300]!),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFF2196F3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey[300]!),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
},
|
||||
onSubmitted: (_) => _performSearch(),
|
||||
textInputAction: TextInputAction.search,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 搜索建议
|
||||
_buildSearchSuggestions(),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
'取消',
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _controller.text.trim().isNotEmpty
|
||||
? _performSearch
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
child: const Text('搜索'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建搜索建议
|
||||
Widget _buildSearchSuggestions() {
|
||||
final suggestions = [
|
||||
'四级阅读',
|
||||
'六级阅读',
|
||||
'托福阅读',
|
||||
'雅思阅读',
|
||||
'商务英语',
|
||||
'日常对话',
|
||||
'科技文章',
|
||||
'新闻报道',
|
||||
];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'热门搜索',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: suggestions.map((suggestion) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_controller.text = suggestion;
|
||||
setState(() {});
|
||||
_performSearch();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 简化的搜索栏组件(用于嵌入页面)
|
||||
class ReadingSearchField extends StatefulWidget {
|
||||
final Function(String) onSearch;
|
||||
final Function()? onTap;
|
||||
final String? initialQuery;
|
||||
final bool enabled;
|
||||
|
||||
const ReadingSearchField({
|
||||
super.key,
|
||||
required this.onSearch,
|
||||
this.onTap,
|
||||
this.initialQuery,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingSearchField> createState() => _ReadingSearchFieldState();
|
||||
}
|
||||
|
||||
class _ReadingSearchFieldState extends State<ReadingSearchField> {
|
||||
late TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialQuery);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
enabled: widget.enabled,
|
||||
onTap: widget.onTap,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索文章...',
|
||||
hintStyle: TextStyle(color: Colors.grey[500]),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
suffixIcon: _controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
widget.onSearch('');
|
||||
setState(() {});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
},
|
||||
onSubmitted: widget.onSearch,
|
||||
textInputAction: TextInputAction.search,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
289
client/lib/features/reading/widgets/reading_stats_card.dart
Normal file
289
client/lib/features/reading/widgets/reading_stats_card.dart
Normal file
@@ -0,0 +1,289 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/reading_stats.dart';
|
||||
|
||||
/// 阅读统计卡片组件
|
||||
class ReadingStatsCard extends StatelessWidget {
|
||||
final ReadingStats stats;
|
||||
|
||||
const ReadingStatsCard({
|
||||
super.key,
|
||||
required this.stats,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF2196F3), Color(0xFF1976D2)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.analytics,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'阅读统计',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 统计数据网格
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.article,
|
||||
label: '已读文章',
|
||||
value: stats.totalArticlesRead.toString(),
|
||||
unit: '篇',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.quiz,
|
||||
label: '练习次数',
|
||||
value: stats.practicesDone.toString(),
|
||||
unit: '次',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.score,
|
||||
label: '平均分数',
|
||||
value: stats.averageScore.toStringAsFixed(1),
|
||||
unit: '分',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.speed,
|
||||
label: '阅读速度',
|
||||
value: stats.averageReadingSpeed.toStringAsFixed(0),
|
||||
unit: '词/分',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.schedule,
|
||||
label: '总时长',
|
||||
value: '${stats.totalReadingTime}分钟',
|
||||
unit: '',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
icon: Icons.local_fire_department,
|
||||
label: '连续天数',
|
||||
value: stats.consecutiveDays.toString(),
|
||||
unit: '天',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 理解准确度进度条
|
||||
_buildAccuracyProgress(),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 词汇掌握进度条
|
||||
_buildVocabularyProgress(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建统计项目
|
||||
Widget _buildStatItem({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
required String unit,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.white70,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (unit.isNotEmpty)
|
||||
Text(
|
||||
unit,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建理解准确度进度条
|
||||
Widget _buildAccuracyProgress() {
|
||||
final accuracy = stats.comprehensionAccuracy;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'理解准确度',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${accuracy.toStringAsFixed(1)}%',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: accuracy / 100,
|
||||
backgroundColor: Colors.white.withOpacity(0.3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_getAccuracyColor(accuracy),
|
||||
),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建词汇掌握进度条
|
||||
Widget _buildVocabularyProgress() {
|
||||
final vocabulary = stats.vocabularyMastered;
|
||||
final maxVocabulary = 10000; // 假设最大词汇量为10000
|
||||
final progress = (vocabulary / maxVocabulary).clamp(0.0, 1.0);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'词汇掌握',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$vocabulary 词',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: Colors.white.withOpacity(0.3),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
Colors.greenAccent,
|
||||
),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取准确度颜色
|
||||
Color _getAccuracyColor(double accuracy) {
|
||||
if (accuracy >= 90) {
|
||||
return Colors.greenAccent;
|
||||
} else if (accuracy >= 70) {
|
||||
return Colors.yellowAccent;
|
||||
} else {
|
||||
return Colors.redAccent;
|
||||
}
|
||||
}
|
||||
}
|
||||
340
client/lib/features/reading/widgets/reading_toolbar.dart
Normal file
340
client/lib/features/reading/widgets/reading_toolbar.dart
Normal file
@@ -0,0 +1,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimensions.dart';
|
||||
import '../../../core/theme/app_text_styles.dart';
|
||||
|
||||
/// 阅读工具栏组件
|
||||
class ReadingToolbar extends StatelessWidget {
|
||||
final VoidCallback? onBookmark;
|
||||
final VoidCallback? onShare;
|
||||
final VoidCallback? onTranslate;
|
||||
final VoidCallback? onHighlight;
|
||||
final VoidCallback? onNote;
|
||||
final VoidCallback? onSettings;
|
||||
final bool isBookmarked;
|
||||
final bool showProgress;
|
||||
final double? progress;
|
||||
|
||||
const ReadingToolbar({
|
||||
super.key,
|
||||
this.onBookmark,
|
||||
this.onShare,
|
||||
this.onTranslate,
|
||||
this.onHighlight,
|
||||
this.onNote,
|
||||
this.onSettings,
|
||||
this.isBookmarked = false,
|
||||
this.showProgress = false,
|
||||
this.progress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadow.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 进度条
|
||||
if (showProgress && progress != null)
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: AppColors.surfaceVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary),
|
||||
minHeight: 2,
|
||||
),
|
||||
|
||||
// 工具栏按钮
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// 书签
|
||||
_ToolbarButton(
|
||||
icon: isBookmarked ? Icons.bookmark : Icons.bookmark_border,
|
||||
label: '书签',
|
||||
onTap: onBookmark,
|
||||
isActive: isBookmarked,
|
||||
),
|
||||
|
||||
// 分享
|
||||
_ToolbarButton(
|
||||
icon: Icons.share_outlined,
|
||||
label: '分享',
|
||||
onTap: onShare,
|
||||
),
|
||||
|
||||
// 翻译
|
||||
_ToolbarButton(
|
||||
icon: Icons.translate_outlined,
|
||||
label: '翻译',
|
||||
onTap: onTranslate,
|
||||
),
|
||||
|
||||
// 高亮
|
||||
_ToolbarButton(
|
||||
icon: Icons.highlight_outlined,
|
||||
label: '高亮',
|
||||
onTap: onHighlight,
|
||||
),
|
||||
|
||||
// 笔记
|
||||
_ToolbarButton(
|
||||
icon: Icons.note_outlined,
|
||||
label: '笔记',
|
||||
onTap: onNote,
|
||||
),
|
||||
|
||||
// 设置
|
||||
_ToolbarButton(
|
||||
icon: Icons.settings_outlined,
|
||||
label: '设置',
|
||||
onTap: onSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolbarButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final bool isActive;
|
||||
|
||||
const _ToolbarButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppDimensions.radiusSm),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.spacingXs,
|
||||
vertical: AppDimensions.spacingXs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isActive ? AppColors.primary : AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: AppTextStyles.labelSmall.copyWith(
|
||||
color: isActive ? AppColors.primary : AppColors.onSurfaceVariant,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读设置底部弹窗
|
||||
class ReadingSettingsBottomSheet extends StatefulWidget {
|
||||
final double fontSize;
|
||||
final double lineHeight;
|
||||
final bool isDarkMode;
|
||||
final Function(double)? onFontSizeChanged;
|
||||
final Function(double)? onLineHeightChanged;
|
||||
final Function(bool)? onDarkModeChanged;
|
||||
|
||||
const ReadingSettingsBottomSheet({
|
||||
super.key,
|
||||
required this.fontSize,
|
||||
required this.lineHeight,
|
||||
required this.isDarkMode,
|
||||
this.onFontSizeChanged,
|
||||
this.onLineHeightChanged,
|
||||
this.onDarkModeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadingSettingsBottomSheet> createState() => _ReadingSettingsBottomSheetState();
|
||||
}
|
||||
|
||||
class _ReadingSettingsBottomSheetState extends State<ReadingSettingsBottomSheet> {
|
||||
late double _fontSize;
|
||||
late double _lineHeight;
|
||||
late bool _isDarkMode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fontSize = widget.fontSize;
|
||||
_lineHeight = widget.lineHeight;
|
||||
_isDarkMode = widget.isDarkMode;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppDimensions.spacingLg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppDimensions.radiusLg),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'阅读设置',
|
||||
style: AppTextStyles.headlineSmall,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
|
||||
// 字体大小
|
||||
Text(
|
||||
'字体大小',
|
||||
style: AppTextStyles.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (_fontSize > 12) {
|
||||
setState(() {
|
||||
_fontSize -= 1;
|
||||
});
|
||||
widget.onFontSizeChanged?.call(_fontSize);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _fontSize,
|
||||
min: 12,
|
||||
max: 24,
|
||||
divisions: 12,
|
||||
label: '${_fontSize.toInt()}',
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_fontSize = value;
|
||||
});
|
||||
widget.onFontSizeChanged?.call(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (_fontSize < 24) {
|
||||
setState(() {
|
||||
_fontSize += 1;
|
||||
});
|
||||
widget.onFontSizeChanged?.call(_fontSize);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 行间距
|
||||
Text(
|
||||
'行间距',
|
||||
style: AppTextStyles.titleMedium,
|
||||
),
|
||||
const SizedBox(height: AppDimensions.spacingSm),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (_lineHeight > 1.2) {
|
||||
setState(() {
|
||||
_lineHeight -= 0.1;
|
||||
});
|
||||
widget.onLineHeightChanged?.call(_lineHeight);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _lineHeight,
|
||||
min: 1.2,
|
||||
max: 2.0,
|
||||
divisions: 8,
|
||||
label: '${(_lineHeight * 10).toInt() / 10}',
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_lineHeight = value;
|
||||
});
|
||||
widget.onLineHeightChanged?.call(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
if (_lineHeight < 2.0) {
|
||||
setState(() {
|
||||
_lineHeight += 0.1;
|
||||
});
|
||||
widget.onLineHeightChanged?.call(_lineHeight);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 夜间模式
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
'夜间模式',
|
||||
style: AppTextStyles.titleMedium,
|
||||
),
|
||||
value: _isDarkMode,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isDarkMode = value;
|
||||
});
|
||||
widget.onDarkModeChanged?.call(value);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: AppDimensions.spacingLg),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user