1328 lines
42 KiB
Dart
1328 lines
42 KiB
Dart
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, // 可以为null,ListView会自动处理
|
||
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')}';
|
||
}
|
||
}
|
||
} |