This commit is contained in:
sjk
2025-11-17 14:09:17 +08:00
commit 31e46c5bf6
479 changed files with 109324 additions and 0 deletions

View File

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

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

View 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ˈː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('确定'),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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('确定'),
),
],
),
);
}
}

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

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