init
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/utils/responsive_utils.dart';
|
||||
import '../../../shared/widgets/custom_app_bar.dart';
|
||||
import '../../../shared/widgets/loading_widget.dart';
|
||||
|
||||
class AIRecommendationScreen extends ConsumerStatefulWidget {
|
||||
const AIRecommendationScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AIRecommendationScreen> createState() => _AIRecommendationScreenState();
|
||||
}
|
||||
|
||||
class _AIRecommendationScreenState extends ConsumerState<AIRecommendationScreen> {
|
||||
bool _isLoading = false;
|
||||
List<RecommendationItem> _recommendations = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRecommendations();
|
||||
}
|
||||
|
||||
Future<void> _loadRecommendations() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// 模拟AI推荐数据加载
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
|
||||
setState(() {
|
||||
_recommendations = _generateRecommendations();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
List<RecommendationItem> _generateRecommendations() {
|
||||
return [
|
||||
RecommendationItem(
|
||||
type: RecommendationType.vocabulary,
|
||||
title: '商务英语词汇强化',
|
||||
description: '基于您的学习历史,建议加强商务场景词汇学习',
|
||||
priority: 'high',
|
||||
estimatedTime: '15分钟',
|
||||
icon: Icons.business_center,
|
||||
color: Colors.blue,
|
||||
action: '开始学习',
|
||||
details: [
|
||||
'包含50个高频商务词汇',
|
||||
'涵盖会议、谈判、报告等场景',
|
||||
'配有真实商务对话示例',
|
||||
],
|
||||
),
|
||||
RecommendationItem(
|
||||
type: RecommendationType.review,
|
||||
title: '复习昨日学习内容',
|
||||
description: '您有8个单词需要复习,趁热打铁效果更佳',
|
||||
priority: 'high',
|
||||
estimatedTime: '10分钟',
|
||||
icon: Icons.refresh,
|
||||
color: Colors.orange,
|
||||
action: '立即复习',
|
||||
details: [
|
||||
'8个单词待复习',
|
||||
'基于遗忘曲线算法推荐',
|
||||
'巩固记忆效果显著',
|
||||
],
|
||||
),
|
||||
RecommendationItem(
|
||||
type: RecommendationType.test,
|
||||
title: '词汇测试挑战',
|
||||
description: '测试您的词汇掌握程度,发现薄弱环节',
|
||||
priority: 'medium',
|
||||
estimatedTime: '20分钟',
|
||||
icon: Icons.quiz,
|
||||
color: Colors.green,
|
||||
action: '开始测试',
|
||||
details: [
|
||||
'30道精选测试题',
|
||||
'多种题型组合',
|
||||
'即时反馈和解析',
|
||||
],
|
||||
),
|
||||
RecommendationItem(
|
||||
type: RecommendationType.plan,
|
||||
title: '制定本周学习计划',
|
||||
description: '为您量身定制的学习计划,提高学习效率',
|
||||
priority: 'medium',
|
||||
estimatedTime: '5分钟',
|
||||
icon: Icons.schedule,
|
||||
color: Colors.purple,
|
||||
action: '查看计划',
|
||||
details: [
|
||||
'个性化学习路径',
|
||||
'合理安排学习时间',
|
||||
'目标导向的学习方案',
|
||||
],
|
||||
),
|
||||
RecommendationItem(
|
||||
type: RecommendationType.weakness,
|
||||
title: '语法薄弱点强化',
|
||||
description: '针对您在时态方面的薄弱点进行专项训练',
|
||||
priority: 'low',
|
||||
estimatedTime: '25分钟',
|
||||
icon: Icons.trending_up,
|
||||
color: Colors.red,
|
||||
action: '开始训练',
|
||||
details: [
|
||||
'时态专项练习',
|
||||
'常见错误纠正',
|
||||
'实用例句训练',
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = ResponsiveUtils.isMobile(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: CustomAppBar(
|
||||
title: 'AI助手推荐',
|
||||
),
|
||||
body: _isLoading
|
||||
? const LoadingWidget()
|
||||
: _buildContent(context, isMobile),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, bool isMobile) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadRecommendations,
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(isMobile ? 16.0 : 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(context, isMobile),
|
||||
const SizedBox(height: 24),
|
||||
_buildRecommendationsList(context, isMobile),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context, bool isMobile) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(isMobile ? 20.0 : 24.0),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.purple.shade400, Colors.blue.shade500],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.purple.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.psychology,
|
||||
color: Colors.white,
|
||||
size: isMobile ? 28 : 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'AI智能推荐',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: isMobile ? 20 : 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'基于您的学习数据智能分析',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb,
|
||||
color: Colors.yellow.shade300,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'今日为您推荐了 ${_recommendations.length} 项学习内容',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: isMobile ? 13 : 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecommendationsList(BuildContext context, bool isMobile) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'推荐内容',
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 18 : 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _recommendations.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 16),
|
||||
itemBuilder: (context, index) {
|
||||
return _buildRecommendationCard(
|
||||
context,
|
||||
_recommendations[index],
|
||||
isMobile,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecommendationCard(
|
||||
BuildContext context,
|
||||
RecommendationItem item,
|
||||
bool isMobile,
|
||||
) {
|
||||
return Card(
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _handleRecommendationTap(item),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isMobile ? 16.0 : 20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: item.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
item.icon,
|
||||
color: item.color,
|
||||
size: isMobile ? 24 : 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 16 : 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildPriorityBadge(item.priority),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
item.estimatedTime,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
item.description,
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
color: Colors.grey[700],
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ExpansionTile(
|
||||
title: const Text(
|
||||
'查看详情',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: const EdgeInsets.only(top: 8),
|
||||
children: [
|
||||
...item.details.map((detail) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: item.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
detail,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _handleRecommendationTap(item),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: item.color,
|
||||
foregroundColor: Colors.white,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: isMobile ? 12 : 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
item.action,
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriorityBadge(String priority) {
|
||||
Color color;
|
||||
String text;
|
||||
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
color = Colors.red;
|
||||
text = '高优先级';
|
||||
break;
|
||||
case 'medium':
|
||||
color = Colors.orange;
|
||||
text = '中优先级';
|
||||
break;
|
||||
case 'low':
|
||||
color = Colors.green;
|
||||
text = '低优先级';
|
||||
break;
|
||||
default:
|
||||
color = Colors.grey;
|
||||
text = '普通';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color, width: 1),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleRecommendationTap(RecommendationItem item) {
|
||||
switch (item.type) {
|
||||
case RecommendationType.vocabulary:
|
||||
Navigator.of(context).pushNamed('/vocabulary/daily-words');
|
||||
break;
|
||||
case RecommendationType.review:
|
||||
Navigator.of(context).pushNamed('/vocabulary/review');
|
||||
break;
|
||||
case RecommendationType.test:
|
||||
Navigator.of(context).pushNamed('/vocabulary/test');
|
||||
break;
|
||||
case RecommendationType.plan:
|
||||
Navigator.of(context).pushNamed('/vocabulary/study-plan');
|
||||
break;
|
||||
case RecommendationType.weakness:
|
||||
// 处理薄弱点强化
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RecommendationType {
|
||||
vocabulary,
|
||||
review,
|
||||
test,
|
||||
plan,
|
||||
weakness,
|
||||
}
|
||||
|
||||
class RecommendationItem {
|
||||
final RecommendationType type;
|
||||
final String title;
|
||||
final String description;
|
||||
final String priority;
|
||||
final String estimatedTime;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String action;
|
||||
final List<String> details;
|
||||
|
||||
const RecommendationItem({
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.priority,
|
||||
required this.estimatedTime,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.action,
|
||||
required this.details,
|
||||
});
|
||||
}
|
||||
851
client/lib/features/vocabulary/screens/daily_words_screen.dart
Normal file
851
client/lib/features/vocabulary/screens/daily_words_screen.dart
Normal file
@@ -0,0 +1,851 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/utils/responsive_utils.dart';
|
||||
import '../../../shared/widgets/custom_app_bar.dart';
|
||||
import '../../../shared/widgets/loading_widget.dart';
|
||||
import '../../../shared/widgets/error_widget.dart' as custom;
|
||||
import '../models/word_model.dart';
|
||||
import '../models/vocabulary_book_model.dart';
|
||||
import '../providers/vocabulary_provider.dart';
|
||||
import '../../auth/providers/auth_provider.dart';
|
||||
import 'word_learning_screen.dart';
|
||||
import '../../../core/services/audio_service.dart';
|
||||
import '../../../shared/services/word_book_service.dart' as shared;
|
||||
import '../services/vocabulary_service.dart';
|
||||
import '../../../core/services/storage_service.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
|
||||
class DailyWordsScreen extends ConsumerStatefulWidget {
|
||||
const DailyWordsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DailyWordsScreen> createState() => _DailyWordsScreenState();
|
||||
}
|
||||
|
||||
class _DailyWordsScreenState extends ConsumerState<DailyWordsScreen> {
|
||||
bool _isLoading = false;
|
||||
List<Word> _dailyWords = [];
|
||||
int _currentIndex = 0;
|
||||
// 已学习的单词ID集合(从后端获取或学习后更新)
|
||||
Set<String> _learnedWordIds = {};
|
||||
// 今日学习统计(从后端获取)
|
||||
int _todayStudiedCount = 0;
|
||||
int _todayNewWords = 0;
|
||||
int _todayReviewWords = 0;
|
||||
// 音频服务
|
||||
final AudioService _audioService = AudioService();
|
||||
// 生词本服务
|
||||
shared.WordBookService? _wordBookService;
|
||||
// 收藏的单词ID集合
|
||||
Set<int> _favoriteWordIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeServices();
|
||||
_loadDailyWords();
|
||||
_loadTodayStatistics(); // 加载今日学习统计
|
||||
}
|
||||
|
||||
Future<void> _initializeServices() async {
|
||||
try {
|
||||
await _audioService.initialize();
|
||||
_wordBookService = shared.WordBookService();
|
||||
await _loadFavoriteWords();
|
||||
} catch (e) {
|
||||
print('初始化服务失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFavoriteWords() async {
|
||||
if (_wordBookService == null) return;
|
||||
try {
|
||||
// 从后端获取生词本列表
|
||||
final result = await _wordBookService!.getFavoriteWords(pageSize: 1000);
|
||||
final List<dynamic> words = result['words'] ?? [];
|
||||
setState(() {
|
||||
_favoriteWordIds = words.map((w) => w['vocabulary_id'] as int).toSet();
|
||||
});
|
||||
print('✅ 已加载 ${_favoriteWordIds.length} 个收藏单词');
|
||||
} catch (e) {
|
||||
print('加载收藏单词失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_audioService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadDailyWords() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
print('=== 加载今日单词 ===');
|
||||
|
||||
// 使用与首页一样的接口获取今日单词
|
||||
final apiClient = ApiClient.instance;
|
||||
final storageService = await StorageService.getInstance();
|
||||
final vocabularyService = VocabularyService(
|
||||
apiClient: apiClient,
|
||||
storageService: storageService,
|
||||
);
|
||||
|
||||
// 调用getTodayStudyWords方法,获取今日学习单词(完整信息)
|
||||
final words = await vocabularyService.getTodayStudyWords(limit: 50);
|
||||
|
||||
setState(() {
|
||||
_dailyWords = words;
|
||||
_isLoading = false;
|
||||
});
|
||||
print('✅ 今日单词加载完成,数量: ${words.length}');
|
||||
} catch (e) {
|
||||
print('❌ 加载今日单词异常: $e');
|
||||
setState(() {
|
||||
_dailyWords = [];
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载今日学习统计
|
||||
Future<void> _loadTodayStatistics() async {
|
||||
try {
|
||||
final apiClient = ApiClient.instance;
|
||||
final response = await apiClient.get('/learning/today/statistics');
|
||||
|
||||
if (response.statusCode == 200 && response.data['code'] == 200) {
|
||||
final data = response.data['data'];
|
||||
setState(() {
|
||||
_todayStudiedCount = (data['todayTotalStudied'] ?? 0).toInt();
|
||||
_todayNewWords = (data['todayNewWords'] ?? 0).toInt();
|
||||
_todayReviewWords = (data['todayReviewWords'] ?? 0).toInt();
|
||||
});
|
||||
print('✅ 今日学习统计: 新词$_todayNewWords + 复习$_todayReviewWords = 总计$_todayStudiedCount');
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ 加载今日学习统计失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _startLearning() async {
|
||||
if (_dailyWords.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('暂无单词可学习')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建一个临时的词汇书用于学习
|
||||
final vocabularyBook = VocabularyBook(
|
||||
id: 'daily_words',
|
||||
name: '今日单词',
|
||||
description: '今日推荐学习的单词',
|
||||
type: VocabularyBookType.system,
|
||||
difficulty: VocabularyBookDifficulty.beginner,
|
||||
totalWords: _dailyWords.length,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final result = await Navigator.of(context).push<Map<String, dynamic>>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WordLearningScreen(
|
||||
vocabularyBook: vocabularyBook,
|
||||
specificWords: _dailyWords,
|
||||
mode: LearningMode.normal,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 从学习页面返回后更新进度
|
||||
if (result != null && result is Map<String, bool>) {
|
||||
_updateProgressFromLearningResult({'answers': result});
|
||||
_loadTodayStatistics(); // 重新加载今日统计(从后端获取最新数据)
|
||||
}
|
||||
}
|
||||
|
||||
void _startReviewMode() async {
|
||||
// 获取已学习的单词
|
||||
final learnedWords = _dailyWords.where((word) => _learnedWordIds.contains(word.id)).toList();
|
||||
|
||||
if (learnedWords.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('暂无已学单词可复习')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建一个临时的词汇书用于复习
|
||||
final vocabularyBook = VocabularyBook(
|
||||
id: 'daily_words_review',
|
||||
name: '今日单词复习',
|
||||
description: '复习今日已学的单词',
|
||||
type: VocabularyBookType.system,
|
||||
difficulty: VocabularyBookDifficulty.beginner,
|
||||
totalWords: learnedWords.length,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final result = await Navigator.of(context).push<Map<String, dynamic>>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WordLearningScreen(
|
||||
vocabularyBook: vocabularyBook,
|
||||
specificWords: learnedWords,
|
||||
mode: LearningMode.review,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 从复习页面返回后更新状态
|
||||
if (result != null && result is Map<String, bool>) {
|
||||
_updateProgressFromReviewResult({'answers': result});
|
||||
_loadTodayStatistics(); // 重新加载今日统计
|
||||
}
|
||||
}
|
||||
|
||||
void _markAsLearned(int index) {
|
||||
setState(() {
|
||||
if (index < _dailyWords.length) {
|
||||
_learnedWordIds.add(_dailyWords[index].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _updateProgressFromLearningResult(Map<String, dynamic> result) {
|
||||
final answers = result['answers'] as Map<String, bool>? ?? {};
|
||||
|
||||
setState(() {
|
||||
// 将答对的单词ID添加到已学习集合
|
||||
answers.forEach((wordId, isCorrect) {
|
||||
if (isCorrect) {
|
||||
_learnedWordIds.add(wordId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
final learnedWordsCount = _learnedWordIds.length;
|
||||
|
||||
// 显示学习结果提示
|
||||
final totalWords = answers.length;
|
||||
if (totalWords > 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('学习完成!掌握了 $learnedWordsCount/$totalWords 个单词'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateProgressFromReviewResult(Map<String, dynamic> result) {
|
||||
final answers = result['answers'] as Map<String, bool>? ?? {};
|
||||
final correctCount = answers.values.where((answer) => answer == true).length;
|
||||
final totalCount = answers.length;
|
||||
|
||||
// 显示复习结果提示
|
||||
if (totalCount > 0) {
|
||||
final accuracy = (correctCount / totalCount * 100).round();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('复习完成!正确率:$accuracy% ($correctCount/$totalCount)'),
|
||||
backgroundColor: accuracy >= 80 ? Colors.green : Colors.orange,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = ResponsiveUtils.isMobile(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: CustomAppBar(
|
||||
title: '今日复习',
|
||||
),
|
||||
body: _isLoading
|
||||
? const LoadingWidget()
|
||||
: _buildContent(context, isMobile),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context, bool isMobile) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isMobile ? 32.0 : 48.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.book_outlined,
|
||||
size: isMobile ? 80 : 100,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'暂无今日单词',
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 20 : 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'今天还没有推荐的学习单词',
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _loadDailyWords,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重新加载'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isMobile ? 24 : 32,
|
||||
vertical: isMobile ? 12 : 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, bool isMobile) {
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(isMobile ? 16.0 : 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 学习统计(始终显示)
|
||||
_buildProgressSection(context, isMobile),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 如果有今日推荐单词,显示操作按钮和单词列表
|
||||
if (_dailyWords.isNotEmpty) ...[
|
||||
_buildActionButtons(context, isMobile),
|
||||
const SizedBox(height: 24),
|
||||
_buildWordsList(context, isMobile),
|
||||
] else ...[
|
||||
// 没有今日需要复习的单词时的提示
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 64,
|
||||
color: Colors.green[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'今天没有需要复习的单词',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'继续保持!去词汇书页面学习新单词吧',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressSection(BuildContext context, bool isMobile) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(isMobile ? 16.0 : 20.0),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.blue.shade400, Colors.blue.shade600],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.today,
|
||||
color: Colors.white,
|
||||
size: isMobile ? 24 : 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'今日学习进度',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: isMobile ? 18 : 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$_todayStudiedCount 个单词',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: isMobile ? 16 : 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'新词 $_todayNewWords + 复习 $_todayReviewWords',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.white,
|
||||
size: isMobile ? 32 : 40,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(BuildContext context, bool isMobile) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _startLearning,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('开始学习'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: isMobile ? 12 : 16,
|
||||
horizontal: isMobile ? 16 : 24,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _startReviewMode,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('复习模式'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.blue,
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: isMobile ? 12 : 16,
|
||||
horizontal: isMobile ? 16 : 24,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWordsList(BuildContext context, bool isMobile) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'单词列表',
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 18 : 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _dailyWords.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
return _buildWordCard(context, _dailyWords[index], index, isMobile);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWordCard(BuildContext context, Word word, int index, bool isMobile) {
|
||||
final isLearned = _learnedWordIds.contains(word.id);
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(isMobile ? 16.0 : 20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
word.word,
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 18 : 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isLearned ? Colors.green : Colors.grey[800],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (isLearned)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.green,
|
||||
size: isMobile ? 20 : 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (word.phonetic != null)
|
||||
Text(
|
||||
word.phonetic!,
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
color: Colors.blue[600],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => _playWordAudio(word),
|
||||
icon: const Icon(Icons.volume_up),
|
||||
color: Colors.blue,
|
||||
tooltip: '播放发音',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _toggleFavorite(word),
|
||||
icon: Icon(
|
||||
_favoriteWordIds.contains(int.tryParse(word.id))
|
||||
? Icons.favorite
|
||||
: Icons.favorite_border,
|
||||
),
|
||||
color: Colors.red,
|
||||
tooltip: _favoriteWordIds.contains(int.tryParse(word.id)) ? '取消收藏' : '收藏单词',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (word.definitions.isNotEmpty)
|
||||
Text(
|
||||
word.definitions.first.translation,
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (word.examples.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
word.examples.first.sentence,
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 13 : 15,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
word.examples.first.translation,
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getDifficultyColor(word.difficulty).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _getDifficultyColor(word.difficulty),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_getDifficultyText(word.difficulty),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getDifficultyColor(word.difficulty),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_getCategoryText(word.definitions.isNotEmpty ? word.definitions.first.type : WordType.noun),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!isLearned)
|
||||
TextButton(
|
||||
onPressed: () => _markAsLearned(index),
|
||||
child: const Text('标记已学'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getDifficultyColor(WordDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case WordDifficulty.beginner:
|
||||
return Colors.green;
|
||||
case WordDifficulty.elementary:
|
||||
return Colors.lightGreen;
|
||||
case WordDifficulty.intermediate:
|
||||
return Colors.orange;
|
||||
case WordDifficulty.advanced:
|
||||
return Colors.red;
|
||||
case WordDifficulty.expert:
|
||||
return Colors.purple;
|
||||
}
|
||||
}
|
||||
|
||||
String _getDifficultyText(WordDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case WordDifficulty.beginner:
|
||||
return '初级';
|
||||
case WordDifficulty.elementary:
|
||||
return '基础';
|
||||
case WordDifficulty.intermediate:
|
||||
return '中级';
|
||||
case WordDifficulty.advanced:
|
||||
return '高级';
|
||||
case WordDifficulty.expert:
|
||||
return '专家';
|
||||
}
|
||||
}
|
||||
|
||||
String _getCategoryText(WordType type) {
|
||||
switch (type) {
|
||||
case WordType.noun:
|
||||
return '名词';
|
||||
case WordType.verb:
|
||||
return '动词';
|
||||
case WordType.adjective:
|
||||
return '形容词';
|
||||
case WordType.adverb:
|
||||
return '副词';
|
||||
case WordType.preposition:
|
||||
return '介词';
|
||||
case WordType.conjunction:
|
||||
return '连词';
|
||||
case WordType.interjection:
|
||||
return '感叹词';
|
||||
case WordType.pronoun:
|
||||
return '代词';
|
||||
case WordType.article:
|
||||
return '冠词';
|
||||
case WordType.phrase:
|
||||
return '短语';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playWordAudio(Word word) async {
|
||||
try {
|
||||
if (word.audioUrl != null && word.audioUrl!.isNotEmpty) {
|
||||
await _audioService.playAudio(word.audioUrl!);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.volume_up, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text('正在播放: ${word.word}'),
|
||||
],
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('该单词暂无音频'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('播放音频失败: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('播放失败'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(Word word) async {
|
||||
if (_wordBookService == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('生词本服务正在初始化,请稍后再试'),
|
||||
duration: Duration(seconds: 2),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用后端 API 切换收藏状态
|
||||
final wordId = int.tryParse(word.id);
|
||||
if (wordId == null) {
|
||||
throw Exception('无效的单词 ID');
|
||||
}
|
||||
|
||||
final result = await _wordBookService!.toggleFavorite(wordId);
|
||||
final isFavorite = result['is_favorite'] as bool;
|
||||
|
||||
setState(() {
|
||||
if (isFavorite) {
|
||||
_favoriteWordIds.add(wordId);
|
||||
} else {
|
||||
_favoriteWordIds.remove(wordId);
|
||||
}
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(isFavorite ? '已添加到生词本: ${word.word}' : '已从生词本移除: ${word.word}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: isFavorite ? Colors.green : Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('操作生词本失败: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('操作失败: ${e.toString()}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
542
client/lib/features/vocabulary/screens/smart_review_screen.dart
Normal file
542
client/lib/features/vocabulary/screens/smart_review_screen.dart
Normal file
@@ -0,0 +1,542 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/vocabulary_book_model.dart';
|
||||
import '../models/review_models.dart';
|
||||
import '../models/word_model.dart';
|
||||
import '../providers/vocabulary_provider.dart';
|
||||
import 'dart:math';
|
||||
|
||||
class SmartReviewScreen extends ConsumerStatefulWidget {
|
||||
final VocabularyBook? vocabularyBook;
|
||||
final ReviewMode reviewMode;
|
||||
final int dailyTarget;
|
||||
|
||||
const SmartReviewScreen({
|
||||
super.key,
|
||||
this.vocabularyBook,
|
||||
this.reviewMode = ReviewMode.adaptive,
|
||||
this.dailyTarget = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<SmartReviewScreen> createState() => _SmartReviewScreenState();
|
||||
}
|
||||
|
||||
class _SmartReviewScreenState extends ConsumerState<SmartReviewScreen> {
|
||||
List<Word> _reviewWords = [];
|
||||
int _currentIndex = 0;
|
||||
bool _isLoading = true;
|
||||
bool _showAnswer = false;
|
||||
Map<int, int> _reviewResults = {}; // 0: 不记得, 1: 模糊, 2: 记得
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadWords();
|
||||
}
|
||||
|
||||
Future<void> _loadWords() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final notifier = ref.read(vocabularyProvider.notifier);
|
||||
await notifier.loadReviewWords();
|
||||
|
||||
final state = ref.read(vocabularyProvider);
|
||||
final words = state.reviewWords;
|
||||
|
||||
if (words.isEmpty) {
|
||||
// 如果没有复习词汇,生成示例数据
|
||||
_reviewWords = _generateSampleWords();
|
||||
} else {
|
||||
_reviewWords = words.take(widget.dailyTarget).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
_reviewWords = _generateSampleWords();
|
||||
}
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
List<Word> _generateSampleWords() {
|
||||
final sampleData = [
|
||||
{'word': 'abandon', 'phonetic': '/əˈbændən/', 'translation': '放弃;遗弃'},
|
||||
{'word': 'ability', 'phonetic': '/əˈbɪləti/', 'translation': '能力;才能'},
|
||||
{'word': 'abroad', 'phonetic': '/əˈbrɔːd/', 'translation': '在国外;到国外'},
|
||||
{'word': 'absence', 'phonetic': '/ˈæbsəns/', 'translation': '缺席;缺乏'},
|
||||
{'word': 'absolute', 'phonetic': '/ˈæbsəluːt/', 'translation': '绝对的;完全的'},
|
||||
{'word': 'absorb', 'phonetic': '/əbˈsɔːrb/', 'translation': '吸收;吸引'},
|
||||
{'word': 'abstract', 'phonetic': '/ˈæbstrækt/', 'translation': '抽象的;抽象概念'},
|
||||
{'word': 'abundant', 'phonetic': '/əˈbʌndənt/', 'translation': '丰富的;充裕的'},
|
||||
{'word': 'academic', 'phonetic': '/ˌækəˈdemɪk/', 'translation': '学术的;学院的'},
|
||||
{'word': 'accept', 'phonetic': '/əkˈsept/', 'translation': '接受;承认'},
|
||||
];
|
||||
|
||||
return List.generate(
|
||||
widget.dailyTarget.clamp(1, sampleData.length),
|
||||
(index) {
|
||||
final data = sampleData[index % sampleData.length];
|
||||
return Word(
|
||||
id: '${index + 1}',
|
||||
word: data['word']!,
|
||||
phonetic: data['phonetic'],
|
||||
difficulty: WordDifficulty.intermediate,
|
||||
frequency: 1000,
|
||||
definitions: [
|
||||
WordDefinition(
|
||||
type: WordType.noun,
|
||||
definition: 'Example definition',
|
||||
translation: data['translation']!,
|
||||
),
|
||||
],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('智能复习'),
|
||||
),
|
||||
body: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_currentIndex >= _reviewWords.length) {
|
||||
return _buildCompleteScreen();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('智能复习 (${_currentIndex + 1}/${_reviewWords.length})'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _showExitConfirmDialog,
|
||||
child: const Text(
|
||||
'退出',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildReviewCard(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReviewCard() {
|
||||
final word = _reviewWords[_currentIndex];
|
||||
final translation = word.definitions.isNotEmpty
|
||||
? word.definitions.first.translation
|
||||
: '示例释义';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 进度条
|
||||
LinearProgressIndicator(
|
||||
value: (_currentIndex + 1) / _reviewWords.length,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF9C27B0)),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 卡片
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 单词
|
||||
Text(
|
||||
word.word,
|
||||
style: const TextStyle(
|
||||
fontSize: 42,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF9C27B0),
|
||||
),
|
||||
),
|
||||
if (word.phonetic != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
word.phonetic!,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
color: Colors.grey,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
// 音频按钮
|
||||
IconButton(
|
||||
onPressed: () => _playAudio(word.word),
|
||||
icon: const Icon(
|
||||
Icons.volume_up,
|
||||
size: 48,
|
||||
color: Color(0xFF9C27B0),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// 答案区域
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
height: _showAnswer ? null : 0,
|
||||
child: _showAnswer
|
||||
? Column(
|
||||
children: [
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'释义',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
translation,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// 提示文本
|
||||
if (!_showAnswer)
|
||||
const Text(
|
||||
'回忆这个单词的意思,然后点击“显示答案”',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部按钮
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: _showAnswer
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildResultButton(
|
||||
label: '不记得',
|
||||
color: Colors.red,
|
||||
result: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildResultButton(
|
||||
label: '模糊',
|
||||
color: Colors.orange,
|
||||
result: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildResultButton(
|
||||
label: '记得',
|
||||
color: Colors.green,
|
||||
result: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showAnswer = true;
|
||||
});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF9C27B0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'显示答案',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultButton({
|
||||
required String label,
|
||||
required Color color,
|
||||
required int result,
|
||||
}) {
|
||||
return ElevatedButton(
|
||||
onPressed: () => _recordResult(result),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _recordResult(int result) {
|
||||
setState(() {
|
||||
_reviewResults[_currentIndex] = result;
|
||||
_showAnswer = false;
|
||||
_currentIndex++;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildCompleteScreen() {
|
||||
final remembered = _reviewResults.values.where((r) => r == 2).length;
|
||||
final fuzzy = _reviewResults.values.where((r) => r == 1).length;
|
||||
final forgotten = _reviewResults.values.where((r) => r == 0).length;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('复习完成'),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF9C27B0).withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.check_circle,
|
||||
size: 64,
|
||||
color: Color(0xFF9C27B0),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'复习完成!',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'共复习 ${_reviewWords.length} 个单词',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// 统计信息
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildStatRow(
|
||||
'完全记得',
|
||||
remembered,
|
||||
Colors.green,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildStatRow(
|
||||
'模糊记得',
|
||||
fuzzy,
|
||||
Colors.orange,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildStatRow(
|
||||
'不记得',
|
||||
forgotten,
|
||||
Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF9C27B0),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 48,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'完成',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_currentIndex = 0;
|
||||
_reviewResults.clear();
|
||||
_showAnswer = false;
|
||||
});
|
||||
_loadWords();
|
||||
},
|
||||
child: const Text('重新复习'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatRow(String label, int count, Color color) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _playAudio(String word) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('正在播放 "$word" 的发音'),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showExitConfirmDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('退出复习'),
|
||||
content: const Text('确定要退出吗?当前进度将不会保存。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1854
client/lib/features/vocabulary/screens/study_plan_screen.dart
Normal file
1854
client/lib/features/vocabulary/screens/study_plan_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
299
client/lib/features/vocabulary/screens/study_screen.dart
Normal file
299
client/lib/features/vocabulary/screens/study_screen.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:ai_english_learning/core/network/api_client.dart';
|
||||
import 'package:ai_english_learning/core/services/storage_service.dart';
|
||||
import 'package:ai_english_learning/features/vocabulary/models/vocabulary_book_model.dart';
|
||||
import 'package:ai_english_learning/features/vocabulary/models/word_model.dart';
|
||||
import 'package:ai_english_learning/features/vocabulary/models/learning_session_model.dart';
|
||||
import 'package:ai_english_learning/features/vocabulary/services/learning_service.dart';
|
||||
import 'package:ai_english_learning/features/vocabulary/services/vocabulary_service.dart';
|
||||
import 'package:ai_english_learning/features/vocabulary/widgets/study_card_widget.dart';
|
||||
|
||||
class StudyScreen extends StatefulWidget {
|
||||
final VocabularyBook vocabularyBook;
|
||||
final int dailyGoal;
|
||||
|
||||
const StudyScreen({
|
||||
Key? key,
|
||||
required this.vocabularyBook,
|
||||
this.dailyGoal = 20,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StudyScreen> createState() => _StudyScreenState();
|
||||
}
|
||||
|
||||
class _StudyScreenState extends State<StudyScreen> {
|
||||
late LearningService _learningService;
|
||||
late VocabularyService _vocabularyService;
|
||||
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
LearningSession? _session;
|
||||
DailyLearningTasks? _tasks;
|
||||
List<Word> _wordsList = [];
|
||||
int _currentIndex = 0;
|
||||
|
||||
int _studiedCount = 0;
|
||||
DateTime? _studyStartTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final storageService = await StorageService.getInstance();
|
||||
_learningService = LearningService(apiClient: ApiClient.instance);
|
||||
_vocabularyService = VocabularyService(
|
||||
apiClient: ApiClient.instance,
|
||||
storageService: storageService,
|
||||
);
|
||||
|
||||
await _startLearning();
|
||||
}
|
||||
|
||||
Future<void> _startLearning() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 开始学习会话
|
||||
final result = await _learningService.startLearning(
|
||||
widget.vocabularyBook.id,
|
||||
widget.dailyGoal,
|
||||
);
|
||||
|
||||
_session = result['session'] as LearningSession;
|
||||
_tasks = result['tasks'] as DailyLearningTasks;
|
||||
|
||||
print('✅ 学习会话创建成功');
|
||||
print('📝 新单词数量: ${_tasks!.newWords.length}');
|
||||
print('🔄 复习单词数量: ${_tasks!.reviewWords.length}');
|
||||
|
||||
// 2. 加载单词详情 - 从词汇书API获取
|
||||
if (_tasks!.newWords.isEmpty && _tasks!.reviewWords.isEmpty) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_wordsList = [];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 合并新单词和复习单词的ID(学习逻辑:新词 + 所有到期复习词)
|
||||
final allWordIds = <int>{
|
||||
..._tasks!.newWords,
|
||||
..._tasks!.reviewWords.map((r) => r['vocabulary_id'] as int),
|
||||
};
|
||||
|
||||
final totalWords = allWordIds.length;
|
||||
print('📚 今日学习任务: ${_tasks!.newWords.length}个新词 + ${_tasks!.reviewWords.length}个复习词 = $totalWords个单词');
|
||||
|
||||
// 使用词汇书的单词加载API - 动态设置limit以包含所有需要的单词
|
||||
final limit = (totalWords / 50).ceil() * 50; // 向上取整到50的倍数
|
||||
final bookWords = await _vocabularyService.getVocabularyBookWords(
|
||||
widget.vocabularyBook.id,
|
||||
page: 1,
|
||||
limit: limit < 100 ? 100 : limit,
|
||||
);
|
||||
|
||||
// 筛选出需要学习的单词(新词 + 复习词)
|
||||
final words = <Word>[];
|
||||
|
||||
for (final bookWord in bookWords) {
|
||||
if (bookWord.word == null) continue;
|
||||
|
||||
final wordId = int.tryParse(bookWord.word!.id);
|
||||
if (wordId != null && allWordIds.contains(wordId)) {
|
||||
words.add(bookWord.word!);
|
||||
}
|
||||
if (words.length >= totalWords) break;
|
||||
}
|
||||
|
||||
print('✅ 加载了 ${words.length} 个单词,需要 $totalWords 个');
|
||||
|
||||
setState(() {
|
||||
_wordsList = words;
|
||||
_isLoading = false;
|
||||
_studyStartTime = DateTime.now();
|
||||
});
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
print('❌ 开始学习失败: $e');
|
||||
print('Stack trace: $stackTrace');
|
||||
setState(() {
|
||||
_error = '加载学习内容失败,请稍后重试\n错误:$e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAnswer(StudyDifficulty difficulty) async {
|
||||
if (_currentIndex >= _wordsList.length) return;
|
||||
|
||||
final word = _wordsList[_currentIndex];
|
||||
final studyTime = _studyStartTime != null
|
||||
? DateTime.now().difference(_studyStartTime!).inMilliseconds
|
||||
: 0;
|
||||
|
||||
try {
|
||||
// 提交学习结果
|
||||
await _learningService.submitWordStudy(
|
||||
word.id,
|
||||
difficulty,
|
||||
sessionId: int.tryParse(_session?.id ?? '0'),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_studiedCount++;
|
||||
_currentIndex++;
|
||||
_studyStartTime = DateTime.now();
|
||||
});
|
||||
|
||||
// 检查是否完成
|
||||
if (_currentIndex >= _wordsList.length) {
|
||||
_showCompletionDialog();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
print('提交学习结果失败: $e');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('提交失败,请重试')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showCompletionDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('🎉 今日学习完成!'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('学习单词数:$_studiedCount'),
|
||||
Text('新学单词:${_tasks?.newWords.length ?? 0}'),
|
||||
Text('复习单词:${_tasks?.reviewWords.length ?? 0}'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop(true); // 返回到上一页
|
||||
},
|
||||
child: const Text('完成'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.vocabularyBook.name),
|
||||
actions: [
|
||||
// 进度显示
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text(
|
||||
'$_studiedCount / ${_wordsList.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _startLearning,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_wordsList.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check_circle, size: 64, color: Colors.green[400]),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'今日任务已完成!',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'明天继续加油!',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('返回'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_currentIndex >= _wordsList.length) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 进度条
|
||||
LinearProgressIndicator(
|
||||
value: _currentIndex / _wordsList.length,
|
||||
backgroundColor: Colors.grey[200],
|
||||
),
|
||||
|
||||
// 学习卡片
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: StudyCardWidget(
|
||||
word: _wordsList[_currentIndex],
|
||||
onAnswer: _handleAnswer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1328
client/lib/features/vocabulary/screens/vocabulary_book_screen.dart
Normal file
1328
client/lib/features/vocabulary/screens/vocabulary_book_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,506 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/vocabulary_book_model.dart';
|
||||
import '../models/vocabulary_book_category.dart';
|
||||
import '../services/vocabulary_service.dart';
|
||||
import '../providers/vocabulary_provider.dart';
|
||||
import '../../../core/routes/app_routes.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/services/storage_service.dart';
|
||||
|
||||
class VocabularyCategoryScreen extends ConsumerStatefulWidget {
|
||||
final String category;
|
||||
|
||||
const VocabularyCategoryScreen({
|
||||
super.key,
|
||||
required this.category,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<VocabularyCategoryScreen> createState() => _VocabularyCategoryScreenState();
|
||||
}
|
||||
|
||||
class _VocabularyCategoryScreenState extends ConsumerState<VocabularyCategoryScreen> {
|
||||
List<VocabularyBook> _categoryBooks = [];
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCategoryBooks();
|
||||
}
|
||||
|
||||
Future<void> _loadCategoryBooks() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiClient = ApiClient.instance;
|
||||
final storageService = await StorageService.getInstance();
|
||||
final vocabularyService = VocabularyService(
|
||||
apiClient: apiClient,
|
||||
storageService: storageService,
|
||||
);
|
||||
|
||||
// 从 API 获取该分类下的词书
|
||||
final books = await vocabularyService.getSystemVocabularyBooks(
|
||||
category: widget.category,
|
||||
limit: 100,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_categoryBooks = books;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.category),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadCategoryBooks,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_categoryBooks.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(
|
||||
'分类: ${widget.category}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isMobile = constraints.maxWidth < 600;
|
||||
return _buildBooksList(context, _categoryBooks, isMobile);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBooksList(BuildContext context, List<VocabularyBook> books, bool isMobile) {
|
||||
return GridView.builder(
|
||||
padding: EdgeInsets.all(isMobile ? 16.0 : 24.0),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isMobile ? 2 : 3,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: isMobile ? 0.75 : 0.8,
|
||||
),
|
||||
itemCount: books.length,
|
||||
itemBuilder: (context, index) {
|
||||
final book = books[index];
|
||||
return _buildBookCard(context, book, isMobile);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBookCard(BuildContext context, VocabularyBook book, bool isMobile) {
|
||||
final difficultyColor = _getDifficultyColor(book.difficulty);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToBook(context, book),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 顶部渐变封面区域
|
||||
Container(
|
||||
height: isMobile ? 120 : 140,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
difficultyColor.withOpacity(0.15),
|
||||
difficultyColor.withOpacity(0.05),
|
||||
],
|
||||
),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 装饰性图案
|
||||
Positioned(
|
||||
right: -20,
|
||||
top: -20,
|
||||
child: Icon(
|
||||
Icons.auto_stories_outlined,
|
||||
size: 100,
|
||||
color: difficultyColor.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
// 书本图标
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: difficultyColor.withOpacity(0.2),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Icons.menu_book_rounded,
|
||||
size: isMobile ? 32 : 40,
|
||||
color: difficultyColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 难度标签
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: difficultyColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: difficultyColor.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
_getDifficultyText(book.difficulty),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 词书信息区域
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 词书名称
|
||||
Text(
|
||||
book.name,
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 15 : 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF2C3E50),
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 词汇量信息
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.library_books_outlined,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${book.totalWords} 词',
|
||||
style: TextStyle(
|
||||
fontSize: isMobile ? 13 : 14,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 学习进度
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final progressAsync = ref.watch(vocabularyBookProgressProvider(book.id));
|
||||
return progressAsync.when(
|
||||
data: (progress) {
|
||||
final percentage = (progress.progressPercentage / 100.0).clamp(0.0, 1.0);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'学习进度',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${progress.progressPercentage.toStringAsFixed(0)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: difficultyColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: LinearProgressIndicator(
|
||||
value: percentage,
|
||||
backgroundColor: difficultyColor.withOpacity(0.1),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(difficultyColor),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'学习进度',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'0%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: difficultyColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: LinearProgressIndicator(
|
||||
value: 0,
|
||||
backgroundColor: difficultyColor.withOpacity(0.1),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(difficultyColor),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
error: (_, __) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'学习进度',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'0%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: difficultyColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: LinearProgressIndicator(
|
||||
value: 0,
|
||||
backgroundColor: difficultyColor.withOpacity(0.1),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(difficultyColor),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
// 底部操作区域
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: difficultyColor.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.play_circle_outline,
|
||||
size: 18,
|
||||
color: difficultyColor,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'开始学习',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: difficultyColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getDifficultyColor(VocabularyBookDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case VocabularyBookDifficulty.beginner:
|
||||
return Colors.green;
|
||||
case VocabularyBookDifficulty.elementary:
|
||||
return Colors.lightGreen;
|
||||
case VocabularyBookDifficulty.intermediate:
|
||||
return Colors.orange;
|
||||
case VocabularyBookDifficulty.advanced:
|
||||
return Colors.red;
|
||||
case VocabularyBookDifficulty.expert:
|
||||
return Colors.purple;
|
||||
}
|
||||
}
|
||||
|
||||
String _getDifficultyText(VocabularyBookDifficulty difficulty) {
|
||||
switch (difficulty) {
|
||||
case VocabularyBookDifficulty.beginner:
|
||||
return '初级';
|
||||
case VocabularyBookDifficulty.elementary:
|
||||
return '基础';
|
||||
case VocabularyBookDifficulty.intermediate:
|
||||
return '中级';
|
||||
case VocabularyBookDifficulty.advanced:
|
||||
return '高级';
|
||||
case VocabularyBookDifficulty.expert:
|
||||
return '专家';
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToBook(BuildContext context, VocabularyBook book) {
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
Routes.vocabularyList,
|
||||
arguments: {'vocabularyBook': book},
|
||||
);
|
||||
}
|
||||
}
|
||||
1761
client/lib/features/vocabulary/screens/vocabulary_home_screen.dart
Normal file
1761
client/lib/features/vocabulary/screens/vocabulary_home_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,507 @@
|
||||
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 'dart:math';
|
||||
|
||||
enum TestType {
|
||||
vocabularyLevel,
|
||||
listening,
|
||||
reading,
|
||||
}
|
||||
|
||||
class VocabularyTestScreen extends ConsumerStatefulWidget {
|
||||
final VocabularyBook? vocabularyBook;
|
||||
final TestType testType;
|
||||
final int questionCount;
|
||||
|
||||
const VocabularyTestScreen({
|
||||
super.key,
|
||||
this.vocabularyBook,
|
||||
this.testType = TestType.vocabularyLevel,
|
||||
this.questionCount = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<VocabularyTestScreen> createState() => _VocabularyTestScreenState();
|
||||
}
|
||||
|
||||
class _VocabularyTestScreenState extends ConsumerState<VocabularyTestScreen> {
|
||||
List<Word> _testWords = [];
|
||||
int _currentIndex = 0;
|
||||
Map<int, String> _userAnswers = {};
|
||||
bool _isLoading = true;
|
||||
bool _isTestComplete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initTest();
|
||||
}
|
||||
|
||||
Future<void> _initTest() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final notifier = ref.read(vocabularyProvider.notifier);
|
||||
await notifier.loadTodayStudyWords();
|
||||
|
||||
final state = ref.read(vocabularyProvider);
|
||||
final allWords = state.todayWords;
|
||||
|
||||
if (allWords.isEmpty) {
|
||||
// 如果没有今日单词,生成示例数据
|
||||
_testWords = _generateSampleWords();
|
||||
} else {
|
||||
// 随机选取指定数量的单词
|
||||
final random = Random();
|
||||
final selectedWords = <Word>[];
|
||||
final wordsCopy = List<Word>.from(allWords);
|
||||
|
||||
final count = widget.questionCount.clamp(1, wordsCopy.length);
|
||||
for (var i = 0; i < count; i++) {
|
||||
if (wordsCopy.isEmpty) break;
|
||||
final index = random.nextInt(wordsCopy.length);
|
||||
selectedWords.add(wordsCopy.removeAt(index));
|
||||
}
|
||||
|
||||
_testWords = selectedWords;
|
||||
}
|
||||
} catch (e) {
|
||||
_testWords = _generateSampleWords();
|
||||
}
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
List<Word> _generateSampleWords() {
|
||||
// 生成示例测试数据
|
||||
final sampleWords = [
|
||||
'abandon', 'ability', 'abroad', 'absence', 'absolute',
|
||||
'absorb', 'abstract', 'abundant', 'academic', 'accept',
|
||||
];
|
||||
|
||||
return List.generate(
|
||||
widget.questionCount.clamp(1, sampleWords.length),
|
||||
(index) => Word(
|
||||
id: '${index + 1}',
|
||||
word: sampleWords[index % sampleWords.length],
|
||||
phonetic: '/ˈsæmpl/',
|
||||
difficulty: WordDifficulty.intermediate,
|
||||
frequency: 1000,
|
||||
definitions: [
|
||||
WordDefinition(
|
||||
type: WordType.noun,
|
||||
definition: 'Example definition',
|
||||
translation: '示例释义 ${index + 1}',
|
||||
),
|
||||
],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('词汇测试'),
|
||||
),
|
||||
body: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isTestComplete) {
|
||||
return _buildResultScreen();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('词汇测试 (${_currentIndex + 1}/${_testWords.length})'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _showExitConfirmDialog,
|
||||
child: const Text(
|
||||
'退出',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildTestQuestion(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTestQuestion() {
|
||||
if (_currentIndex >= _testWords.length) {
|
||||
return const Center(child: Text('测试已完成'));
|
||||
}
|
||||
|
||||
final word = _testWords[_currentIndex];
|
||||
final correctAnswer = word.definitions.isNotEmpty
|
||||
? word.definitions.first.translation
|
||||
: '示例释义';
|
||||
|
||||
// 生成选项(一个正确答案 + 三个干扰项)
|
||||
final options = _generateOptions(correctAnswer, _currentIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 进度条
|
||||
LinearProgressIndicator(
|
||||
value: (_currentIndex + 1) / _testWords.length,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF2196F3)),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 问题区域
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'请选择下列单词的正确释义',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
word.word,
|
||||
style: const TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
if (word.phonetic != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
word.phonetic!,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// 选项
|
||||
...options.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final option = entry.value;
|
||||
final optionLabel = String.fromCharCode(65 + index); // A, B, C, D
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildOptionCard(
|
||||
label: optionLabel,
|
||||
text: option,
|
||||
isSelected: _userAnswers[_currentIndex] == option,
|
||||
onTap: () => _selectAnswer(option),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 底部按钮
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ElevatedButton(
|
||||
onPressed: _userAnswers.containsKey(_currentIndex) ? _nextQuestion : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_currentIndex < _testWords.length - 1 ? '下一题' : '完成测试',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionCard({
|
||||
required String label,
|
||||
required String text,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFF2196F3).withOpacity(0.1)
|
||||
: Colors.white,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF2196F3)
|
||||
: Colors.grey[300]!,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFF2196F3)
|
||||
: Colors.grey[200],
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected ? Colors.white : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: isSelected ? const Color(0xFF2196F3) : Colors.black87,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _generateOptions(String correctAnswer, int questionIndex) {
|
||||
final distractors = [
|
||||
'放弃;遗弃',
|
||||
'能力;才能',
|
||||
'在国外;到国外',
|
||||
'缺席;缺乏',
|
||||
'绝对的;完全的',
|
||||
'吸收;吸引',
|
||||
'抽象的;抽象概念',
|
||||
'丰富的;充裕的',
|
||||
'学术的;学院的',
|
||||
'接受;承认',
|
||||
];
|
||||
|
||||
final options = <String>[correctAnswer];
|
||||
final random = Random(questionIndex); // 使用问题索引作为种子以保证一致性
|
||||
|
||||
while (options.length < 4 && distractors.isNotEmpty) {
|
||||
final index = random.nextInt(distractors.length);
|
||||
final distractor = distractors[index];
|
||||
if (distractor != correctAnswer && !options.contains(distractor)) {
|
||||
options.add(distractor);
|
||||
}
|
||||
distractors.removeAt(index);
|
||||
}
|
||||
|
||||
// 打乱选项顺序
|
||||
options.shuffle(random);
|
||||
return options;
|
||||
}
|
||||
|
||||
void _selectAnswer(String answer) {
|
||||
setState(() {
|
||||
_userAnswers[_currentIndex] = answer;
|
||||
});
|
||||
}
|
||||
|
||||
void _nextQuestion() {
|
||||
if (_currentIndex < _testWords.length - 1) {
|
||||
setState(() {
|
||||
_currentIndex++;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isTestComplete = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildResultScreen() {
|
||||
int correctCount = 0;
|
||||
|
||||
for (var i = 0; i < _testWords.length; i++) {
|
||||
final word = _testWords[i];
|
||||
final correctAnswer = word.definitions.isNotEmpty
|
||||
? word.definitions.first.translation
|
||||
: '示例释义';
|
||||
if (_userAnswers[i] == correctAnswer) {
|
||||
correctCount++;
|
||||
}
|
||||
}
|
||||
|
||||
final score = (correctCount / _testWords.length * 100).round();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('测试结果'),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: score >= 60
|
||||
? Colors.green.withOpacity(0.1)
|
||||
: Colors.orange.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$score',
|
||||
style: TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: score >= 60 ? Colors.green : Colors.orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
score >= 80 ? '太棒了!' : score >= 60 ? '不错哦!' : '加油!',
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'你的分数:$score 分',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'正确率:$correctCount/${_testWords.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 48,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'完成',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_currentIndex = 0;
|
||||
_userAnswers.clear();
|
||||
_isTestComplete = false;
|
||||
});
|
||||
_initTest();
|
||||
},
|
||||
child: const Text('重新测试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showExitConfirmDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('退出测试'),
|
||||
content: const Text('确定要退出吗?当前进度将不会保存。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
432
client/lib/features/vocabulary/screens/word_book_screen.dart
Normal file
432
client/lib/features/vocabulary/screens/word_book_screen.dart
Normal file
@@ -0,0 +1,432 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../shared/services/word_book_service.dart';
|
||||
import '../../../core/services/tts_service.dart';
|
||||
|
||||
/// 生词本页面
|
||||
class WordBookScreen extends ConsumerStatefulWidget {
|
||||
const WordBookScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<WordBookScreen> createState() => _WordBookScreenState();
|
||||
}
|
||||
|
||||
class _WordBookScreenState extends ConsumerState<WordBookScreen> {
|
||||
final TTSService _ttsService = TTSService();
|
||||
bool _isLoading = true;
|
||||
List<dynamic> _words = [];
|
||||
Map<String, dynamic>? _stats;
|
||||
int _currentPage = 1;
|
||||
final int _pageSize = 20;
|
||||
String _sortBy = 'created_at';
|
||||
String _order = 'desc';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ttsService.initialize();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ttsService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final wordBookService = ref.read(wordBookServiceProvider);
|
||||
|
||||
// 加载生词列表
|
||||
final wordsData = await wordBookService.getFavoriteWords(
|
||||
page: _currentPage,
|
||||
pageSize: _pageSize,
|
||||
sortBy: _sortBy,
|
||||
order: _order,
|
||||
);
|
||||
|
||||
// 加载统计信息
|
||||
final stats = await wordBookService.getFavoriteStats();
|
||||
|
||||
setState(() {
|
||||
_words = wordsData['words'];
|
||||
_stats = stats;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('加载失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeWord(int wordId) async {
|
||||
try {
|
||||
final wordBookService = ref.read(wordBookServiceProvider);
|
||||
await wordBookService.removeFromFavorite(wordId);
|
||||
|
||||
_loadData(); // 刷新列表
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已从生词本移除')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('移除失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
title: const Text('生词本'),
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
foregroundColor: Colors.white,
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
setState(() {
|
||||
if (value == 'proficiency_asc') {
|
||||
_sortBy = 'proficiency';
|
||||
_order = 'asc';
|
||||
} else if (value == 'proficiency_desc') {
|
||||
_sortBy = 'proficiency';
|
||||
_order = 'desc';
|
||||
} else if (value == 'word_asc') {
|
||||
_sortBy = 'word';
|
||||
_order = 'asc';
|
||||
} else if (value == 'created_at_desc') {
|
||||
_sortBy = 'created_at';
|
||||
_order = 'desc';
|
||||
}
|
||||
_currentPage = 1;
|
||||
_loadData();
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'created_at_desc',
|
||||
child: Text('按添加时间排序'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'proficiency_asc',
|
||||
child: Text('按熟练度(低到高)'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'proficiency_desc',
|
||||
child: Text('按熟练度(高到低)'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'word_asc',
|
||||
child: Text('按单词字母排序'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: Column(
|
||||
children: [
|
||||
if (_stats != null) _buildStatsCard(),
|
||||
Expanded(
|
||||
child: _words.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildWordList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsCard() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'学习统计',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
'总词数',
|
||||
_stats!['total_words'].toString(),
|
||||
Icons.book,
|
||||
const Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
'已掌握',
|
||||
_stats!['mastered_words'].toString(),
|
||||
Icons.check_circle,
|
||||
const Color(0xFF4CAF50),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
'复习中',
|
||||
_stats!['reviewing_words'].toString(),
|
||||
Icons.loop,
|
||||
const Color(0xFFFF9800),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem(String label, String value, IconData icon, Color color) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 24),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bookmark_border,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'生词本为空',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'在学习时点击收藏按钮添加生词',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWordList() {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _words.length,
|
||||
itemBuilder: (context, index) {
|
||||
final word = _words[index];
|
||||
return _buildWordCard(word);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWordCard(Map<String, dynamic> word) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
word['word'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (word['phonetic'] != null)
|
||||
Text(
|
||||
word['phonetic'],
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
if (word['definitions'] != null)
|
||||
Text(
|
||||
word['definitions'],
|
||||
style: const TextStyle(fontSize: 14),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_buildLevelBadge(word['level']),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'熟练度: ${word['proficiency'] ?? 0}%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _getProficiencyColor(word['proficiency'] ?? 0),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.volume_up, color: Color(0xFF2196F3)),
|
||||
onPressed: () async {
|
||||
await _ttsService.speak(word['word'] ?? '');
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('确认移除'),
|
||||
content: Text('确定要将"${word['word']}"从生词本移除吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_removeWord(word['id']);
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLevelBadge(String? level) {
|
||||
Color color;
|
||||
String text;
|
||||
|
||||
switch (level) {
|
||||
case 'beginner':
|
||||
color = const Color(0xFF4CAF50);
|
||||
text = '初级';
|
||||
break;
|
||||
case 'intermediate':
|
||||
color = const Color(0xFF2196F3);
|
||||
text = '中级';
|
||||
break;
|
||||
case 'advanced':
|
||||
color = const Color(0xFFFF9800);
|
||||
text = '高级';
|
||||
break;
|
||||
default:
|
||||
color = Colors.grey;
|
||||
text = '未知';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getProficiencyColor(int proficiency) {
|
||||
if (proficiency >= 80) return const Color(0xFF4CAF50);
|
||||
if (proficiency >= 60) return const Color(0xFF2196F3);
|
||||
if (proficiency >= 40) return const Color(0xFFFF9800);
|
||||
return const Color(0xFFF44336);
|
||||
}
|
||||
}
|
||||
621
client/lib/features/vocabulary/screens/word_learning_screen.dart
Normal file
621
client/lib/features/vocabulary/screens/word_learning_screen.dart
Normal file
@@ -0,0 +1,621 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/word_model.dart';
|
||||
import '../models/vocabulary_book_model.dart';
|
||||
import '../providers/vocabulary_provider.dart';
|
||||
import '../../../core/services/audio_service.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
enum LearningMode { normal, review, test }
|
||||
|
||||
class WordLearningScreen extends ConsumerStatefulWidget {
|
||||
final VocabularyBook vocabularyBook;
|
||||
final List<Word>? specificWords;
|
||||
final LearningMode mode;
|
||||
|
||||
const WordLearningScreen({
|
||||
super.key,
|
||||
required this.vocabularyBook,
|
||||
this.specificWords,
|
||||
this.mode = LearningMode.normal,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<WordLearningScreen> createState() => _WordLearningScreenState();
|
||||
}
|
||||
|
||||
class _WordLearningScreenState extends ConsumerState<WordLearningScreen> {
|
||||
int _currentIndex = 0;
|
||||
bool _showMeaning = false;
|
||||
final AudioService _audioService = AudioService();
|
||||
Map<String, bool> _studyResults = {}; // 记录每个单词的学习结果
|
||||
List<Word> _words = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_audioService.initialize();
|
||||
_words = widget.specificWords ?? [];
|
||||
|
||||
// 如果没有指定单词,这里可以从 Provider 加载
|
||||
if (_words.isEmpty) {
|
||||
print('⚠️ 没有指定学习单词');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_audioService.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_words.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.vocabularyBook.name),
|
||||
),
|
||||
body: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.book_outlined, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text('暂无可学习的单词'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.vocabularyBook.name),
|
||||
actions: [
|
||||
// 显示当前进度
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'${_currentIndex + 1}/${_words.length}',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 进度条
|
||||
LinearProgressIndicator(
|
||||
value: (_currentIndex + 1) / _words.length,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildLearningCard(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLearningCard() {
|
||||
final word = _words[_currentIndex];
|
||||
// 根据学习模式判断:review模式为复习,其他为新词
|
||||
final isNewWord = widget.mode != LearningMode.review;
|
||||
|
||||
return Container(
|
||||
color: Colors.grey[50],
|
||||
child: Column(
|
||||
children: [
|
||||
// 上半部分:单词卡片
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 新词/复习标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isNewWord
|
||||
? Colors.orange.withOpacity(0.15)
|
||||
: Colors.blue.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isNewWord ? Icons.fiber_new_rounded : Icons.refresh_rounded,
|
||||
size: 18,
|
||||
color: isNewWord ? Colors.orange[700] : Colors.blue[700],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isNewWord ? '新词' : '复习',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isNewWord ? Colors.orange[700] : Colors.blue[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 单词
|
||||
Text(
|
||||
word.word,
|
||||
style: const TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF2C3E50),
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 音标 + 发音按钮
|
||||
if (word.phonetic != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
word.phonetic!,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey[700],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
InkWell(
|
||||
onTap: () => _playAudio(word),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.volume_up_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 显示释义按钮
|
||||
if (!_showMeaning)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showMeaning = true;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.primary, AppColors.primary.withOpacity(0.8)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withOpacity(0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.visibility_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'点击查看释义',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 下半部分:释义内容(点击后显示)
|
||||
if (_showMeaning)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildMeaningContent(word),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 底部按钮区
|
||||
if (_showMeaning)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _buildActionButtons(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildMeaningContent(Word word) {
|
||||
return [
|
||||
// 释义区域
|
||||
if (word.definitions.isNotEmpty) ...[
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.book_rounded, size: 20, color: AppColors.primary),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'释义',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF2C3E50),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...word.definitions.take(3).map((def) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF2C3E50),
|
||||
height: 1.5,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${_getWordTypeText(def.type)} ',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
TextSpan(text: def.translation),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// 例句区域
|
||||
if (word.examples.isNotEmpty) ...[
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.blue[50]!,
|
||||
Colors.blue[50]!.withOpacity(0.5),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.lightbulb_outline_rounded, size: 20, color: Colors.blue[700]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'例句',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
word.examples.first.sentence,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Color(0xFF2C3E50),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
word.examples.first.translation,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _handleStudyResult(false),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.red[400]!, width: 2),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.close_rounded, color: Colors.red[600], size: 24),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'不认识',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.red[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _handleStudyResult(true),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.green[400]!, Colors.green[600]!],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.green.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check_rounded, color: Colors.white, size: 24),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'认识',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handleStudyResult(bool isKnown) {
|
||||
// 记录学习结果
|
||||
_studyResults[_words[_currentIndex].id] = isKnown;
|
||||
|
||||
// 如果是最后一个单词,显示结果
|
||||
if (_currentIndex >= _words.length - 1) {
|
||||
_showResults();
|
||||
} else {
|
||||
// 下一个单词
|
||||
setState(() {
|
||||
_currentIndex++;
|
||||
_showMeaning = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showResults() {
|
||||
final knownCount = _studyResults.values.where((v) => v).length;
|
||||
final totalCount = _studyResults.length;
|
||||
final accuracy = totalCount > 0 ? (knownCount / totalCount * 100).round() : 0;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('学习完成!'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.celebration,
|
||||
size: 64,
|
||||
color: accuracy >= 80 ? Colors.green : Colors.orange,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'正确率:$accuracy%',
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('认识 $knownCount/$totalCount 个单词'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(); // 关闭对话框
|
||||
Navigator.of(context).pop(_studyResults); // 返回结果
|
||||
},
|
||||
child: const Text('完成'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _playAudio(Word word) async {
|
||||
try {
|
||||
if (word.audioUrl != null && word.audioUrl!.isNotEmpty) {
|
||||
await _audioService.playAudio(word.audioUrl!);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('该单词暂无音频')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('播放音频失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _getWordTypeText(WordType type) {
|
||||
switch (type) {
|
||||
case WordType.noun:
|
||||
return '名词';
|
||||
case WordType.verb:
|
||||
return '动词';
|
||||
case WordType.adjective:
|
||||
return '形容词';
|
||||
case WordType.adverb:
|
||||
return '副词';
|
||||
case WordType.preposition:
|
||||
return '介词';
|
||||
case WordType.conjunction:
|
||||
return '连词';
|
||||
case WordType.interjection:
|
||||
return '感叹词';
|
||||
case WordType.pronoun:
|
||||
return '代词';
|
||||
case WordType.article:
|
||||
return '冠词';
|
||||
case WordType.phrase:
|
||||
return '短语';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveProgress() async {
|
||||
final notifier = ref.read(vocabularyProvider.notifier);
|
||||
await notifier.loadUserVocabularyOverallStats();
|
||||
await notifier.loadWeeklyStudyStats();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user