Files
ai_english/client/lib/features/vocabulary/screens/vocabulary_book_screen.dart
2025-11-17 14:09:17 +08:00

1328 lines
42 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/vocabulary_book_model.dart';
import '../models/word_model.dart';
import '../providers/vocabulary_provider.dart';
import '../services/vocabulary_service.dart';
import '../../../core/network/api_client.dart';
import '../../../core/services/storage_service.dart';
import '../../../shared/widgets/custom_app_bar.dart';
import '../../../shared/widgets/custom_card.dart';
import '../../../shared/widgets/loading_widget.dart';
import '../../../shared/widgets/error_widget.dart' as custom_error;
import 'word_learning_screen.dart';
import 'study_screen.dart';
/// 词汇书详情页面
class VocabularyBookScreen extends ConsumerStatefulWidget {
final VocabularyBook vocabularyBook;
const VocabularyBookScreen({
super.key,
required this.vocabularyBook,
});
@override
ConsumerState<VocabularyBookScreen> createState() => _VocabularyBookScreenState();
}
class _VocabularyBookScreenState extends ConsumerState<VocabularyBookScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
ScrollController? _scrollController;
List<Word> _words = [];
List<Word> _filteredWords = []; // 过滤后的单词列表
bool _isLoading = false;
bool _isLoadingMore = false; // 是否正在加载更多
String? _error;
UserVocabularyBookProgress? _progress;
bool _isLoadingProgress = false;
// 搜索相关
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
// 分页相关
int _currentPage = 1;
final int _pageSize = 50; // 每频50个单词
bool _hasMore = true; // 是否还有更多数据
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_scrollController = ScrollController();
_scrollController?.addListener(_onScroll);
_loadVocabularyBookWords(); // 加载第一页
_loadProgress();
// 监听搜索输入变化
_searchController.addListener(_onSearchChanged);
}
/// 监听滚动,实现懒加载
void _onScroll() {
if (_scrollController == null) return;
// 如果滚动到底部80%的位置,且不在加载中,且还有更多数据
if (_scrollController!.position.pixels >= _scrollController!.position.maxScrollExtent * 0.8 &&
!_isLoadingMore &&
_hasMore &&
_tabController.index == 0) { // 只在单词列表标签触发
_loadMoreWords();
}
}
/// 加载更多单词
Future<void> _loadMoreWords() async {
if (_isLoadingMore || !_hasMore) return;
setState(() {
_isLoadingMore = true;
});
try {
_currentPage++;
final vocabularyService = VocabularyService(
apiClient: ApiClient.instance,
storageService: await StorageService.getInstance(),
);
final bookWords = await vocabularyService.getVocabularyBookWords(
widget.vocabularyBook.id,
page: _currentPage,
limit: _pageSize,
);
final newWords = bookWords
.where((bw) => bw.word != null)
.map((bw) => bw.word!)
.toList();
setState(() {
_words.addAll(newWords);
_filteredWords = _searchQuery.isEmpty ? _words : _filterWords(_searchQuery);
_isLoadingMore = false;
// 如果返回的数据少于pageSize说明没有更多数据了
_hasMore = newWords.length >= _pageSize;
});
} catch (e) {
print('加载更多单词失败: $e');
setState(() {
_isLoadingMore = false;
_currentPage--; // 回退页码
});
}
}
/// 搜索输入变化监听
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filteredWords = _searchQuery.isEmpty ? _words : _filterWords(_searchQuery);
});
}
/// 过滤单词
List<Word> _filterWords(String query) {
final lowerQuery = query.toLowerCase();
return _words.where((word) {
// 搜索单词、音标、释义
final wordMatch = word.word.toLowerCase().contains(lowerQuery);
final phoneticMatch = word.phonetic?.toLowerCase().contains(lowerQuery) ?? false;
final definitionMatch = word.definitions.any(
(def) => def.translation.toLowerCase().contains(lowerQuery),
);
return wordMatch || phoneticMatch || definitionMatch;
}).toList();
}
/// 切换搜索状态
void _toggleSearch() {
setState(() {
_isSearching = !_isSearching;
if (!_isSearching) {
_searchController.clear();
_searchQuery = '';
_filteredWords = _words;
}
});
}
@override
void dispose() {
_scrollController?.removeListener(_onScroll);
_scrollController?.dispose();
_tabController.dispose();
_searchController.dispose();
super.dispose();
}
/// 从后端API加载词汇书单词第一页
Future<void> _loadVocabularyBookWords() async {
setState(() {
_isLoading = true;
_error = null;
_currentPage = 1;
_hasMore = true;
});
try {
final vocabularyService = VocabularyService(
apiClient: ApiClient.instance,
storageService: await StorageService.getInstance(),
);
final bookWords = await vocabularyService.getVocabularyBookWords(
widget.vocabularyBook.id,
page: 1,
limit: _pageSize,
);
// 提取单词对象
final words = bookWords
.where((bw) => bw.word != null)
.map((bw) => bw.word!)
.toList();
setState(() {
_words = words;
_filteredWords = words; // 初始化过滤列表
_isLoading = false;
});
} catch (e) {
print('加载词汇书单词失败: $e');
setState(() {
_error = '加载失败,请稍后重试';
_isLoading = false;
// API失败时显示空状态不使用模拟数据
_words = [];
});
}
}
/// 加载学习进度
Future<void> _loadProgress({bool forceRefresh = false}) async {
setState(() {
_isLoadingProgress = true;
});
try {
final vocabularyService = VocabularyService(
apiClient: ApiClient.instance,
storageService: await StorageService.getInstance(),
);
final progress = await vocabularyService.getVocabularyBookProgress(
widget.vocabularyBook.id,
forceRefresh: forceRefresh, // 传递强制刷新参数
);
setState(() {
_progress = progress;
_isLoadingProgress = false;
});
} catch (e) {
print('加载学习进度失败: $e');
setState(() {
_isLoadingProgress = false;
// 使用默认进度
_progress = UserVocabularyBookProgress(
id: '0',
userId: '0',
vocabularyBookId: widget.vocabularyBook.id,
learnedWords: 0,
masteredWords: 0,
progressPercentage: 0.0,
streakDays: 0,
totalStudyDays: 0,
averageDailyWords: 0.0,
startedAt: DateTime.now(),
lastStudiedAt: null,
);
});
}
}
void _handleMenuAction(String action) {
switch (action) {
case 'start_learning':
_startLearning(context);
break;
case 'vocabulary_test':
_startVocabularyTest();
break;
case 'smart_review':
_startSmartReview();
break;
case 'export':
_exportWords();
break;
}
}
void _startLearning(BuildContext context) async {
// 显示学习目标选择对话框
double selectedGoal = 20.0;
final dailyGoal = await showDialog<int>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: const Text('设置学习目标'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('请选择每日学习单词数:'),
const SizedBox(height: 8),
Text(
'${selectedGoal.toInt()}个单词',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
const SizedBox(height: 16),
Slider(
value: selectedGoal,
min: 5,
max: 50,
divisions: 9,
label: '${selectedGoal.toInt()}个单词',
onChanged: (value) {
setState(() {
selectedGoal = value;
});
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('5', style: TextStyle(color: Colors.grey[600])),
Text('50', style: TextStyle(color: Colors.grey[600])),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(selectedGoal.toInt()),
child: const Text('开始学习'),
),
],
);
},
),
);
if (dailyGoal == null) return;
// 导航到学习页面
final result = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => StudyScreen(
vocabularyBook: widget.vocabularyBook,
dailyGoal: dailyGoal,
),
),
);
// 学习完成后强制刷新进度(跳过缓存)
if (result == true) {
_loadProgress(forceRefresh: true);
}
}
void _startVocabularyTest() {
Navigator.of(context).pushNamed(
'/vocabulary_test',
arguments: {
'vocabularyBook': widget.vocabularyBook,
'testType': 'bookTest',
'questionCount': 20,
},
);
}
void _startSmartReview() {
final reviewWords = _words.where((word) {
// 这里可以根据学习进度和遗忘曲线来筛选需要复习的单词
// 暂时返回所有单词
return true;
}).toList();
if (reviewWords.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('暂无需要复习的单词')),
);
return;
}
Navigator.of(context).pushNamed(
'/smart_review',
arguments: {
'vocabularyBook': widget.vocabularyBook,
'words': reviewWords,
'reviewMode': 'adaptive',
},
);
}
void _exportWords() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('导出单词'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.text_snippet),
title: const Text('导出为文本文件'),
onTap: () {
Navigator.of(context).pop();
_exportAsText();
},
),
ListTile(
leading: const Icon(Icons.table_chart),
title: const Text('导出为Excel文件'),
onTap: () {
Navigator.of(context).pop();
_exportAsExcel();
},
),
ListTile(
leading: const Icon(Icons.picture_as_pdf),
title: const Text('导出为PDF文件'),
onTap: () {
Navigator.of(context).pop();
_exportAsPDF();
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
],
),
);
}
void _exportAsText() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('正在导出为文本文件...')),
);
// TODO: 实现文本导出功能
}
void _exportAsExcel() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('正在导出为Excel文件...')),
);
// TODO: 实现Excel导出功能
}
void _exportAsPDF() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('正在导出为PDF文件...')),
);
// TODO: 实现PDF导出功能
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: TabAppBar(
title: widget.vocabularyBook.name,
controller: _tabController,
tabs: const [
Tab(text: '单词列表'),
Tab(text: '学习进度'),
],
actions: [
IconButton(
icon: Icon(_isSearching ? Icons.close : Icons.search),
onPressed: _toggleSearch,
),
PopupMenuButton<String>(
onSelected: _handleMenuAction,
itemBuilder: (context) => [
const PopupMenuItem(
value: 'start_learning',
child: Row(
children: [
Icon(Icons.school),
SizedBox(width: 8),
Text('开始学习'),
],
),
),
const PopupMenuItem(
value: 'vocabulary_test',
child: Row(
children: [
Icon(Icons.quiz),
SizedBox(width: 8),
Text('词汇测试'),
],
),
),
const PopupMenuItem(
value: 'smart_review',
child: Row(
children: [
Icon(Icons.psychology),
SizedBox(width: 8),
Text('智能复习'),
],
),
),
const PopupMenuItem(
value: 'export',
child: Row(
children: [
Icon(Icons.download),
SizedBox(width: 8),
Text('导出单词'),
],
),
),
],
),
],
),
body: Column(
children: [
// 搜索框
if (_isSearching)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: '搜索单词、音标或释义...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
width: 2,
),
),
filled: true,
fillColor: Colors.grey[50],
),
),
),
// TabBarView
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildWordListTab(),
_buildProgressTab(),
],
),
),
],
),
floatingActionButton: _buildFloatingActionButton(),
);
}
Widget _buildFloatingActionButton() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton.extended(
onPressed: () => _startLearning(context),
icon: const Icon(Icons.school),
label: const Text('开始学习'),
heroTag: 'start_learning',
),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: () => _startVocabularyTest(),
child: const Icon(Icons.quiz),
heroTag: 'vocabulary_test',
),
],
);
}
Widget _buildWordListTab() {
if (_isLoading) {
return const Center(child: LoadingWidget());
}
if (_error != null) {
return Center(
child: custom_error.PageErrorWidget(
message: _error!,
onRetry: _loadVocabularyBookWords,
),
);
}
if (_words.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.book_outlined,
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 RefreshIndicator(
onRefresh: _loadVocabularyBookWords,
child: ListView.builder(
controller: _scrollController, // 可以为nullListView会自动处理
padding: const EdgeInsets.all(16),
itemCount: _filteredWords.length + (_hasMore && _searchQuery.isEmpty ? 1 : 0), // 搜索时不显示加载更多
itemBuilder: (context, index) {
// 搜索结果提示
if (_searchQuery.isNotEmpty && index == 0 && _filteredWords.isEmpty) {
return _buildNoResultsWidget();
}
// 如果是最后一项且还有更多数据,显示加载指示器
if (index == _filteredWords.length && _searchQuery.isEmpty) {
return _buildLoadingIndicator();
}
final word = _filteredWords[index];
return _buildWordCard(word, index);
},
),
);
}
/// 构建无搜索结果提示
Widget _buildNoResultsWidget() {
return Center(
child: Padding(
padding: const EdgeInsets.all(48),
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],
),
),
],
),
),
);
}
/// 构建加载更多指示器
Widget _buildLoadingIndicator() {
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
children: [
const CircularProgressIndicator(),
const SizedBox(height: 8),
Text(
'正在加载更多...',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
);
}
Widget _buildWordCard(Word word, int index) {
return CustomCard(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => _showWordDetail(word),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
word.word,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (word.phonetic != null) ...[
const SizedBox(width: 8),
Text(
word.phonetic!,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
],
),
if (word.definitions.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
word.definitions.first.translation,
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
IconButton(
icon: const Icon(Icons.volume_up),
onPressed: () => _playWordPronunciation(word),
),
],
),
if (word.examples.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
word.examples.first.sentence,
style: const TextStyle(
fontSize: 13,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 4),
Text(
word.examples.first.translation,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
],
],
),
),
),
);
}
void _showWordDetail(Word word) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.symmetric(vertical: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.all(24),
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
word.word,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
if (word.phonetic != null) ...[
const SizedBox(height: 4),
Text(
word.phonetic!,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
],
],
),
),
IconButton(
icon: const Icon(Icons.volume_up, size: 32),
onPressed: () => _playWordPronunciation(word),
),
],
),
const SizedBox(height: 24),
const Text(
'释义',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
...word.definitions.map((def) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_getWordTypeText(def.type),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
def.translation,
style: const TextStyle(fontSize: 16),
),
),
],
),
)),
if (word.examples.isNotEmpty) ...[
const SizedBox(height: 24),
const Text(
'例句',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
...word.examples.map((example) => Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
example.sentence,
style: const TextStyle(
fontSize: 15,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 8),
Text(
example.translation,
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
),
],
),
)),
],
if (word.synonyms.isNotEmpty) ...[
const SizedBox(height: 24),
const Text(
'同义词',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: word.synonyms.map((synonym) => Chip(
label: Text(synonym),
backgroundColor: Colors.blue[50],
)).toList(),
),
],
if (word.antonyms.isNotEmpty) ...[
const SizedBox(height: 24),
const Text(
'反义词',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: word.antonyms.map((antonym) => Chip(
label: Text(antonym),
backgroundColor: Colors.orange[50],
)).toList(),
),
],
],
),
),
],
),
),
),
);
}
String _getWordTypeText(WordType type) {
switch (type) {
case WordType.noun:
return 'n.';
case WordType.verb:
return 'v.';
case WordType.adjective:
return 'adj.';
case WordType.adverb:
return 'adv.';
case WordType.pronoun:
return 'pron.';
case WordType.preposition:
return 'prep.';
case WordType.conjunction:
return 'conj.';
case WordType.interjection:
return 'interj.';
case WordType.article:
return 'art.';
case WordType.phrase:
return 'phrase';
}
}
void _playWordPronunciation(Word word) {
// TODO: 实现音频播放功能
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('播放 ${word.word} 的发音')),
);
}
Widget _buildProgressTab() {
if (_isLoadingProgress) {
return const Center(child: LoadingWidget());
}
if (_progress == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.trending_up, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'暂无学习进度',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () => _loadProgress(forceRefresh: true), // 下拉刷新强制跳过缓存
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// 整体进度卡片
CustomCard(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'整体进度',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${_progress!.progressPercentage.toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
],
),
const SizedBox(height: 20),
// 进度条
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: LinearProgressIndicator(
value: _progress!.progressPercentage / 100,
minHeight: 20,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor,
),
),
),
const SizedBox(height: 16),
// 进度详情
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_progress!.learnedWords} / ${widget.vocabularyBook.totalWords} 个单词',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
Text(
'剩余 ${widget.vocabularyBook.totalWords - _progress!.learnedWords}',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
// 单词状态统计
Row(
children: [
Expanded(
child: _buildStatCard(
'已学习',
_progress!.learnedWords.toString(),
Icons.school,
Colors.blue,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
'已掌握',
_progress!.masteredWords.toString(),
Icons.check_circle,
Colors.green,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildStatCard(
'待复习',
'0',
Icons.refresh,
Colors.orange,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
'未学习',
'${widget.vocabularyBook.totalWords - _progress!.learnedWords}',
Icons.fiber_new,
Colors.grey,
),
),
],
),
const SizedBox(height: 16),
// 学习信息
CustomCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'学习信息',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildInfoRow(
Icons.calendar_today,
'累计学习',
'${_progress!.totalStudyDays}',
),
const Divider(height: 24),
_buildInfoRow(
Icons.trending_up,
'掌握率',
'${(_progress!.masteredWords / widget.vocabularyBook.totalWords * 100).toStringAsFixed(1)}%',
),
const Divider(height: 24),
_buildInfoRow(
Icons.access_time,
'上次学习',
_progress!.lastStudiedAt != null
? _formatDateTime(_progress!.lastStudiedAt!)
: '从未学习',
),
const Divider(height: 24),
_buildInfoRow(
Icons.play_circle_outline,
'开始时间',
_formatDateTime(_progress!.startedAt),
),
],
),
),
),
],
),
);
}
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
return CustomCard(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 24, color: color),
),
const SizedBox(height: 12),
Text(
value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
);
}
Widget _buildInfoRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(icon, size: 20, color: Colors.grey[600]),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
),
),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
);
}
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inDays == 0) {
if (difference.inHours == 0) {
if (difference.inMinutes == 0) {
return '刚刚';
}
return '${difference.inMinutes} 分钟前';
}
return '${difference.inHours} 小时前';
} else if (difference.inDays == 1) {
return '昨天';
} else if (difference.inDays < 7) {
return '${difference.inDays} 天前';
} else {
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}';
}
}
}