init
This commit is contained in:
604
client/lib/features/speaking/screens/ai_conversation_screen.dart
Normal file
604
client/lib/features/speaking/screens/ai_conversation_screen.dart
Normal file
@@ -0,0 +1,604 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/ai_tutor.dart';
|
||||
import '../models/conversation.dart';
|
||||
|
||||
/// AI对话页面
|
||||
class AIConversationScreen extends StatefulWidget {
|
||||
final AITutor tutor;
|
||||
|
||||
const AIConversationScreen({
|
||||
super.key,
|
||||
required this.tutor,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AIConversationScreen> createState() => _AIConversationScreenState();
|
||||
}
|
||||
|
||||
class _AIConversationScreenState extends State<AIConversationScreen> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<ConversationMessage> _messages = [];
|
||||
bool _isTyping = false;
|
||||
bool _isRecording = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeConversation();
|
||||
}
|
||||
|
||||
void _initializeConversation() {
|
||||
// 添加导师的欢迎消息
|
||||
final welcomeMessage = ConversationMessage(
|
||||
id: 'welcome_${DateTime.now().millisecondsSinceEpoch}',
|
||||
content: _getWelcomeMessage(),
|
||||
type: MessageType.ai,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_messages.add(welcomeMessage);
|
||||
});
|
||||
}
|
||||
|
||||
String _getWelcomeMessage() {
|
||||
switch (widget.tutor.type) {
|
||||
case TutorType.business:
|
||||
return '${widget.tutor.introduction}\n\n让我们开始商务英语对话练习吧!你可以告诉我你的工作背景,或者我们可以模拟一个商务场景。';
|
||||
case TutorType.daily:
|
||||
return '${widget.tutor.introduction}\n\n今天想聊什么呢?我们可以谈论天气、兴趣爱好,或者你今天做了什么有趣的事情!';
|
||||
case TutorType.travel:
|
||||
return '${widget.tutor.introduction}\n\n准备好开始我们的旅行英语之旅了吗?告诉我你想去哪里旅行,或者我们可以模拟在机场、酒店的场景!';
|
||||
case TutorType.academic:
|
||||
return '${widget.tutor.introduction}\n\n欢迎来到学术英语课堂!我们可以讨论你的研究领域,或者练习学术演讲技巧。';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
backgroundColor: widget.tutor.type.color,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.tutor.avatar,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.tutor.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.tutor.type.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline, color: Colors.white),
|
||||
onPressed: _showTutorInfo,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _messages.length + (_isTyping ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _messages.length && _isTyping) {
|
||||
return _buildTypingIndicator();
|
||||
}
|
||||
return _buildMessageBubble(_messages[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildInputArea(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(ConversationMessage message) {
|
||||
final isUser = message.type == MessageType.user;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: widget.tutor.type.color.withOpacity(0.1),
|
||||
child: Text(
|
||||
widget.tutor.avatar,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? widget.tutor.type.color : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
message.content,
|
||||
style: TextStyle(
|
||||
color: isUser ? Colors.white : Colors.black87,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
color: isUser ? Colors.white70 : Colors.grey,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
const SizedBox(width: 8),
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.blue.withOpacity(0.1),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
size: 16,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypingIndicator() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: widget.tutor.type.color.withOpacity(0.1),
|
||||
child: Text(
|
||||
widget.tutor.avatar,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDot(0),
|
||||
const SizedBox(width: 4),
|
||||
_buildDot(1),
|
||||
const SizedBox(width: 4),
|
||||
_buildDot(2),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDot(int index) {
|
||||
return AnimatedContainer(
|
||||
duration: Duration(milliseconds: 600 + (index * 200)),
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.tutor.type.color.withOpacity(0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputArea() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 5,
|
||||
offset: Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTapDown: (_) => _startRecording(),
|
||||
onTapUp: (_) => _stopRecording(),
|
||||
onTapCancel: () => _stopRecording(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: _isRecording ? Colors.red : widget.tutor.type.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isRecording ? Icons.stop : Icons.mic,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入消息...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.grey[100],
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: _sendMessage,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.tutor.type.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.send,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
// 添加用户消息
|
||||
final userMessage = ConversationMessage(
|
||||
id: 'user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
content: text,
|
||||
type: MessageType.user,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_messages.add(userMessage);
|
||||
_isTyping = true;
|
||||
});
|
||||
|
||||
_messageController.clear();
|
||||
_scrollToBottom();
|
||||
|
||||
// 生成AI回复
|
||||
_generateAIResponse(text);
|
||||
}
|
||||
|
||||
void _generateAIResponse(String userMessage) {
|
||||
// 模拟AI思考时间
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
final aiResponse = _getAIResponse(userMessage);
|
||||
|
||||
final aiMessage = ConversationMessage(
|
||||
id: 'ai_${DateTime.now().millisecondsSinceEpoch}',
|
||||
content: aiResponse,
|
||||
type: MessageType.ai,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isTyping = false;
|
||||
_messages.add(aiMessage);
|
||||
});
|
||||
|
||||
_scrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
String _getAIResponse(String userMessage) {
|
||||
final message = userMessage.toLowerCase();
|
||||
|
||||
// 根据导师类型和用户消息生成相应回复
|
||||
switch (widget.tutor.type) {
|
||||
case TutorType.business:
|
||||
return _getBusinessResponse(message);
|
||||
case TutorType.daily:
|
||||
return _getDailyResponse(message);
|
||||
case TutorType.travel:
|
||||
return _getTravelResponse(message);
|
||||
case TutorType.academic:
|
||||
return _getAcademicResponse(message);
|
||||
}
|
||||
}
|
||||
|
||||
String _getBusinessResponse(String message) {
|
||||
if (message.contains('meeting') || message.contains('会议')) {
|
||||
return "Great! Let's discuss meeting preparation. What type of meeting are you attending? Is it a client presentation, team meeting, or board meeting?";
|
||||
} else if (message.contains('presentation') || message.contains('演讲')) {
|
||||
return "Presentations are crucial in business. What's your presentation topic? I can help you structure your content and practice key phrases.";
|
||||
} else if (message.contains('email') || message.contains('邮件')) {
|
||||
return "Business emails require professional tone. Are you writing to a client, colleague, or supervisor? What's the main purpose of your email?";
|
||||
} else if (message.contains('hello') || message.contains('hi') || message.contains('你好')) {
|
||||
return "Hello! I'm your business English tutor. I can help you with presentations, meetings, negotiations, and professional communication. What would you like to practice today?";
|
||||
} else {
|
||||
return "That's an interesting point. In business context, we should consider the professional implications. Could you elaborate on your specific business scenario?";
|
||||
}
|
||||
}
|
||||
|
||||
String _getDailyResponse(String message) {
|
||||
if (message.contains('weather') || message.contains('天气')) {
|
||||
return "The weather is a great conversation starter! How's the weather where you are? You can say 'It's sunny/rainy/cloudy today' or 'What a beautiful day!'";
|
||||
} else if (message.contains('food') || message.contains('吃') || message.contains('饭')) {
|
||||
return "Food is always a fun topic! What's your favorite cuisine? You can practice ordering food or describing flavors. Try saying 'I'd like to order...' or 'This tastes delicious!'";
|
||||
} else if (message.contains('hobby') || message.contains('爱好')) {
|
||||
return "Hobbies are personal and interesting! What do you enjoy doing in your free time? You can say 'I enjoy...' or 'My hobby is...' or 'In my spare time, I like to...'";
|
||||
} else if (message.contains('hello') || message.contains('hi') || message.contains('你好')) {
|
||||
return "Hi there! I'm here to help you with everyday English conversations. We can talk about weather, food, hobbies, shopping, or any daily activities. What interests you?";
|
||||
} else {
|
||||
return "That's interesting! In daily conversations, we often share personal experiences. Can you tell me more about that? It's great practice for natural English!";
|
||||
}
|
||||
}
|
||||
|
||||
String _getTravelResponse(String message) {
|
||||
if (message.contains('airport') || message.contains('flight') || message.contains('机场')) {
|
||||
return "Airport conversations are essential for travelers! Are you checking in, going through security, or asking for directions? Try phrases like 'Where is gate B12?' or 'Is this flight delayed?'";
|
||||
} else if (message.contains('hotel') || message.contains('酒店')) {
|
||||
return "Hotel interactions are important! Are you checking in, asking about amenities, or reporting an issue? Practice saying 'I have a reservation under...' or 'Could you help me with...'";
|
||||
} else if (message.contains('restaurant') || message.contains('餐厅')) {
|
||||
return "Dining out while traveling is fun! Are you making a reservation, ordering food, or asking about local specialties? Try 'Table for two, please' or 'What do you recommend?'";
|
||||
} else if (message.contains('direction') || message.contains('路')) {
|
||||
return "Getting directions is crucial when traveling! Practice asking 'How do I get to...?' or 'Is it walking distance?' You can also say 'Could you show me on the map?'";
|
||||
} else if (message.contains('hello') || message.contains('hi') || message.contains('你好')) {
|
||||
return "Welcome, fellow traveler! I'm your travel English companion. I can help you with airport conversations, hotel bookings, restaurant orders, and asking for directions. Where shall we start?";
|
||||
} else {
|
||||
return "That sounds like a great travel experience! When traveling, it's important to communicate clearly. Can you describe the situation in more detail? I'll help you with the right phrases!";
|
||||
}
|
||||
}
|
||||
|
||||
String _getAcademicResponse(String message) {
|
||||
if (message.contains('research') || message.contains('研究')) {
|
||||
return "Research is fundamental in academics! What's your research area? We can practice presenting findings, discussing methodology, or explaining complex concepts clearly.";
|
||||
} else if (message.contains('presentation') || message.contains('论文')) {
|
||||
return "Academic presentations require clear structure and precise language. Are you presenting research results, defending a thesis, or giving a conference talk? Let's work on your key points!";
|
||||
} else if (message.contains('discussion') || message.contains('讨论')) {
|
||||
return "Academic discussions involve critical thinking and evidence-based arguments. What topic are you discussing? Practice phrases like 'According to the research...' or 'The evidence suggests...'";
|
||||
} else if (message.contains('writing') || message.contains('写作')) {
|
||||
return "Academic writing has specific conventions. Are you working on an essay, research paper, or thesis? I can help with structure, citations, and formal language.";
|
||||
} else if (message.contains('hello') || message.contains('hi') || message.contains('你好')) {
|
||||
return "Greetings! I'm your academic English tutor. I specialize in research discussions, academic presentations, scholarly writing, and conference communications. What academic skill would you like to develop?";
|
||||
} else {
|
||||
return "That's a thoughtful academic point. In scholarly discourse, we need to support our arguments with evidence. Could you provide more context or examples to strengthen your position?";
|
||||
}
|
||||
}
|
||||
|
||||
void _startRecording() {
|
||||
setState(() {
|
||||
_isRecording = true;
|
||||
});
|
||||
// TODO: 实现语音录制功能
|
||||
}
|
||||
|
||||
void _stopRecording() {
|
||||
setState(() {
|
||||
_isRecording = false;
|
||||
});
|
||||
// TODO: 处理录制的语音
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showTutorInfo() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.tutor.avatar,
|
||||
style: const TextStyle(fontSize: 40),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.tutor.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.tutor.type.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: widget.tutor.type.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'个性特点',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: widget.tutor.type.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.tutor.personality,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'专业领域',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: widget.tutor.type.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: widget.tutor.specialties.map((specialty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.tutor.type.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: widget.tutor.type.color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
specialty,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: widget.tutor.type.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/pronunciation_item.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../services/speaking_service.dart';
|
||||
import '../providers/speaking_provider.dart';
|
||||
import 'pronunciation_practice_screen.dart';
|
||||
|
||||
class PronunciationListScreen extends StatefulWidget {
|
||||
final PronunciationType type;
|
||||
|
||||
const PronunciationListScreen({
|
||||
super.key,
|
||||
required this.type,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PronunciationListScreen> createState() => _PronunciationListScreenState();
|
||||
}
|
||||
|
||||
class _PronunciationListScreenState extends State<PronunciationListScreen> {
|
||||
List<PronunciationItem> _items = [];
|
||||
DifficultyLevel? _selectedDifficulty;
|
||||
String? _selectedCategory;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadItems();
|
||||
}
|
||||
|
||||
void _loadItems() {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final service = SpeakingService();
|
||||
service.getPronunciationItems(widget.type, limit: 50).then((resp) {
|
||||
if (resp.success && resp.data != null) {
|
||||
setState(() {
|
||||
_items = resp.data!;
|
||||
_loading = false;
|
||||
});
|
||||
_applyFilters();
|
||||
} else {
|
||||
setState(() {
|
||||
_error = resp.message;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}).catchError((e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
List<PronunciationItem> filteredItems = List.of(_items);
|
||||
|
||||
if (_selectedDifficulty != null) {
|
||||
filteredItems = filteredItems
|
||||
.where((item) => item.difficulty == _selectedDifficulty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (_selectedCategory != null) {
|
||||
filteredItems = filteredItems
|
||||
.where((item) => item.category == _selectedCategory)
|
||||
.toList();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_items = filteredItems;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F7FA),
|
||||
appBar: AppBar(
|
||||
title: Text(widget.type.displayName),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
onPressed: _showFilterDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
_buildFilterChips(),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? Center(child: Text(_error!, style: const TextStyle(color: Colors.red)))
|
||||
: _buildItemList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(20),
|
||||
bottomRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.type.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.type.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.type.icon,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'共 ${_items.length} 个练习项目',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChips() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
if (_selectedDifficulty != null)
|
||||
_buildFilterChip(
|
||||
'${_selectedDifficulty!.displayName} (${_selectedDifficulty!.code})',
|
||||
() {
|
||||
setState(() {
|
||||
_selectedDifficulty = null;
|
||||
_applyFilters();
|
||||
});
|
||||
},
|
||||
true,
|
||||
),
|
||||
if (_selectedCategory != null)
|
||||
_buildFilterChip(
|
||||
_selectedCategory!,
|
||||
() {
|
||||
setState(() {
|
||||
_selectedCategory = null;
|
||||
_applyFilters();
|
||||
});
|
||||
},
|
||||
true,
|
||||
),
|
||||
if (_selectedDifficulty == null && _selectedCategory == null)
|
||||
const Text(
|
||||
'点击右上角筛选按钮进行筛选',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip(String label, VoidCallback onDeleted, bool showDelete) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
child: Chip(
|
||||
label: Text(label),
|
||||
onDeleted: showDelete ? onDeleted : null,
|
||||
backgroundColor: Colors.blue.withOpacity(0.1),
|
||||
deleteIconColor: Colors.blue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemList() {
|
||||
if (_items.isEmpty) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'没有找到符合条件的练习项目',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(20),
|
||||
itemCount: _items.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildItemCard(_items[index]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemCard(PronunciationItem item) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PronunciationPracticeScreen(item: item),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.text,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildDifficultyBadge(item.difficulty),
|
||||
],
|
||||
),
|
||||
if (item.type != PronunciationType.sentence && item.phonetic.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.phonetic,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.blue,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
item.category,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
Icons.tips_and_updates,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${item.tips.length} 个提示',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDifficultyBadge(DifficultyLevel difficulty) {
|
||||
Color color;
|
||||
switch (difficulty) {
|
||||
case DifficultyLevel.beginner:
|
||||
color = Colors.green;
|
||||
break;
|
||||
case DifficultyLevel.intermediate:
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case DifficultyLevel.advanced:
|
||||
color = Colors.red;
|
||||
break;
|
||||
}
|
||||
|
||||
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.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
difficulty.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFilterDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('筛选条件'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('难度级别:'),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: DifficultyLevel.values.map((difficulty) {
|
||||
return FilterChip(
|
||||
label: Text(difficulty.displayName),
|
||||
selected: _selectedDifficulty == difficulty,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_selectedDifficulty = selected ? difficulty : null;
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('分类:'),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: _getCategories().map((category) {
|
||||
return FilterChip(
|
||||
label: Text(category),
|
||||
selected: _selectedCategory == category,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_selectedCategory = selected ? category : null;
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedDifficulty = null;
|
||||
_selectedCategory = null;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
_applyFilters();
|
||||
},
|
||||
child: const Text('清除'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_applyFilters();
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _getCategories() {
|
||||
final set = <String>{};
|
||||
for (final item in _items) {
|
||||
if (item.category.isNotEmpty) set.add(item.category);
|
||||
}
|
||||
return set.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/pronunciation_item.dart';
|
||||
import '../services/speech_recognition_service.dart';
|
||||
|
||||
class PronunciationPracticeScreen extends StatefulWidget {
|
||||
final PronunciationItem item;
|
||||
|
||||
const PronunciationPracticeScreen({
|
||||
super.key,
|
||||
required this.item,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PronunciationPracticeScreen> createState() => _PronunciationPracticeScreenState();
|
||||
}
|
||||
|
||||
class _PronunciationPracticeScreenState extends State<PronunciationPracticeScreen>
|
||||
with TickerProviderStateMixin {
|
||||
bool _isRecording = false;
|
||||
bool _isPlaying = false;
|
||||
bool _hasRecorded = false;
|
||||
double _currentScore = 0.0;
|
||||
String _feedback = '';
|
||||
int _attempts = 0;
|
||||
|
||||
late AnimationController _waveController;
|
||||
late AnimationController _scoreController;
|
||||
late Animation<double> _waveAnimation;
|
||||
late Animation<double> _scoreAnimation;
|
||||
|
||||
final SpeechRecognitionService _speechService = SpeechRecognitionService();
|
||||
PronunciationResult? _lastResult;
|
||||
String _recognizedText = '';
|
||||
double _currentVolume = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initAnimations();
|
||||
_initSpeechRecognition();
|
||||
}
|
||||
|
||||
void _initSpeechRecognition() {
|
||||
// 监听音量变化
|
||||
_speechService.volumeStream.listen((volume) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentVolume = volume;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 监听识别结果
|
||||
_speechService.recognitionStream.listen((text) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_recognizedText = text;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _initAnimations() {
|
||||
_waveController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scoreController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_waveAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _waveController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_scoreAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _scoreController,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_waveController.dispose();
|
||||
_scoreController.dispose();
|
||||
_speechService.stopListening();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F7FA),
|
||||
appBar: AppBar(
|
||||
title: Text(widget.item.type.displayName),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: _showTipsDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTextCard(),
|
||||
const SizedBox(height: 20),
|
||||
if (widget.item.type != PronunciationType.sentence && widget.item.phonetic.isNotEmpty)
|
||||
_buildPhoneticCard(),
|
||||
const SizedBox(height: 20),
|
||||
_buildRecordingArea(),
|
||||
const SizedBox(height: 20),
|
||||
if (_hasRecorded) _buildScoreCard(),
|
||||
const SizedBox(height: 20),
|
||||
_buildTipsCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextCard() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.item.type.icon,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
widget.item.category,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildDifficultyBadge(widget.item.difficulty),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.item.text,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneticCard() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.record_voice_over,
|
||||
color: Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'音标',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isPlaying ? Icons.stop : Icons.play_arrow,
|
||||
color: Colors.blue,
|
||||
),
|
||||
onPressed: _playStandardAudio,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.item.phonetic,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
color: Colors.blue,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordingArea() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'点击录音按钮开始练习',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AnimatedBuilder(
|
||||
animation: _waveAnimation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _isRecording ? Colors.red.withOpacity(0.1) : Colors.blue.withOpacity(0.1),
|
||||
border: Border.all(
|
||||
color: _isRecording ? Colors.red : Colors.blue,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (_isRecording)
|
||||
...List.generate(3, (index) {
|
||||
return AnimatedContainer(
|
||||
duration: Duration(milliseconds: 500 + index * 200),
|
||||
width: 120 + (index * 20) * _waveAnimation.value,
|
||||
height: 120 + (index * 20) * _waveAnimation.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.red.withOpacity(0.3 - index * 0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
IconButton(
|
||||
iconSize: 48,
|
||||
icon: Icon(
|
||||
_isRecording ? Icons.stop : Icons.mic,
|
||||
color: _isRecording ? Colors.red : Colors.blue,
|
||||
),
|
||||
onPressed: _toggleRecording,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_isRecording ? '录音中...' : '点击开始录音',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: _isRecording ? Colors.red : Colors.grey,
|
||||
),
|
||||
),
|
||||
if (_isRecording) ...[
|
||||
const SizedBox(height: 12),
|
||||
// 音量指示器
|
||||
Container(
|
||||
width: 200,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: _currentVolume,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'音量: ${(_currentVolume * 100).toInt()}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_recognizedText.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'识别结果:',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_recognizedText,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_attempts > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'已练习 $_attempts 次',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScoreCard() {
|
||||
return AnimatedBuilder(
|
||||
animation: _scoreAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: 0.8 + 0.2 * _scoreAnimation.value,
|
||||
child: Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
_getScoreColor().withOpacity(0.1),
|
||||
_getScoreColor().withOpacity(0.05),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.score,
|
||||
color: _getScoreColor(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'发音评分',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'${(_currentScore * _scoreAnimation.value).toInt()}',
|
||||
style: TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _getScoreColor(),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'/100',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: _getScoreColor(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_getScoreText(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: _getScoreColor(),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (_lastResult != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
// 准确度显示
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'准确度: ${_lastResult!.accuracy.displayName}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _getAccuracyColor(_lastResult!.accuracy),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_lastResult!.accuracy.emoji,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 详细分析
|
||||
if (_lastResult!.detailedAnalysis.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'详细分析:',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _lastResult!.detailedAnalysis.entries.map((entry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'${entry.key}: ${entry.value.toInt()}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// 改进建议
|
||||
if (_lastResult!.suggestions.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'改进建议:',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
...(_lastResult!.suggestions.take(2).map((suggestion) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'• $suggestion',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// 反馈
|
||||
if (_feedback.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_feedback,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _toggleRecording,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('再次练习'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTipsCard() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
color: Colors.orange,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'发音提示',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...widget.item.tips.map((tip) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'• ',
|
||||
style: TextStyle(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tip,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDifficultyBadge(DifficultyLevel difficulty) {
|
||||
Color color;
|
||||
switch (difficulty) {
|
||||
case DifficultyLevel.beginner:
|
||||
color = Colors.green;
|
||||
break;
|
||||
case DifficultyLevel.intermediate:
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case DifficultyLevel.advanced:
|
||||
color = Colors.red;
|
||||
break;
|
||||
}
|
||||
|
||||
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.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
'${difficulty.displayName} ${difficulty.code}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getScoreColor() {
|
||||
if (_currentScore >= 80) return Colors.green;
|
||||
if (_currentScore >= 60) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
String _getScoreText() {
|
||||
if (_currentScore >= 90) return '优秀!';
|
||||
if (_currentScore >= 80) return '良好';
|
||||
if (_currentScore >= 70) return '一般';
|
||||
if (_currentScore >= 60) return '需要改进';
|
||||
return '继续努力';
|
||||
}
|
||||
|
||||
Color _getAccuracyColor(AccuracyLevel accuracy) {
|
||||
switch (accuracy) {
|
||||
case AccuracyLevel.excellent:
|
||||
return Colors.green;
|
||||
case AccuracyLevel.good:
|
||||
return Colors.blue;
|
||||
case AccuracyLevel.fair:
|
||||
return Colors.orange;
|
||||
case AccuracyLevel.needsImprovement:
|
||||
return Colors.amber;
|
||||
case AccuracyLevel.poor:
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
|
||||
void _playStandardAudio() {
|
||||
setState(() {
|
||||
_isPlaying = !_isPlaying;
|
||||
});
|
||||
|
||||
// TODO: 实现音频播放功能
|
||||
// 这里应该播放标准发音音频
|
||||
|
||||
// 模拟播放时间
|
||||
if (_isPlaying) {
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPlaying = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleRecording() async {
|
||||
if (_isRecording) {
|
||||
// 停止录音
|
||||
setState(() {
|
||||
_isRecording = false;
|
||||
});
|
||||
_waveController.stop();
|
||||
_waveController.reset();
|
||||
|
||||
await _speechService.stopListening();
|
||||
_analyzeRecording();
|
||||
} else {
|
||||
// 开始录音
|
||||
final hasPermission = await _speechService.checkMicrophonePermission();
|
||||
if (!hasPermission) {
|
||||
final granted = await _speechService.requestMicrophonePermission();
|
||||
if (!granted) {
|
||||
_showPermissionDialog();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final started = await _speechService.startListening();
|
||||
if (started) {
|
||||
setState(() {
|
||||
_isRecording = true;
|
||||
_recognizedText = '';
|
||||
});
|
||||
_waveController.repeat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _analyzeRecording() async {
|
||||
try {
|
||||
final result = await _speechService.analyzePronunciation(
|
||||
_recognizedText,
|
||||
widget.item,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_attempts++;
|
||||
_lastResult = result;
|
||||
_currentScore = result.score;
|
||||
_hasRecorded = true;
|
||||
_feedback = result.feedback;
|
||||
});
|
||||
|
||||
_scoreController.forward();
|
||||
} catch (e) {
|
||||
// 如果分析失败,显示错误信息
|
||||
setState(() {
|
||||
_feedback = '分析失败,请重试';
|
||||
_hasRecorded = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _generateFeedback() {
|
||||
List<String> feedbacks = [
|
||||
'发音基本准确,注意语调的变化',
|
||||
'重音位置正确,继续保持',
|
||||
'音素发音清晰,语速可以稍微放慢',
|
||||
'整体表现不错,注意连读的处理',
|
||||
'发音标准,语调自然',
|
||||
];
|
||||
return feedbacks[DateTime.now().millisecond % feedbacks.length];
|
||||
}
|
||||
|
||||
void _showTipsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('发音提示'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widget.item.tips.map((tip) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'• ',
|
||||
style: TextStyle(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tip,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('知道了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPermissionDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('需要麦克风权限'),
|
||||
content: const Text('为了进行发音练习,需要访问您的麦克风。请在设置中允许麦克风权限。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
// TODO: 打开应用设置页面
|
||||
},
|
||||
child: const Text('去设置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/conversation_scenario.dart';
|
||||
|
||||
/// 场景练习页面
|
||||
class ScenarioPracticeScreen extends StatefulWidget {
|
||||
final ConversationScenario scenario;
|
||||
|
||||
const ScenarioPracticeScreen({
|
||||
Key? key,
|
||||
required this.scenario,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ScenarioPracticeScreen> createState() => _ScenarioPracticeScreenState();
|
||||
}
|
||||
|
||||
class _ScenarioPracticeScreenState extends State<ScenarioPracticeScreen> {
|
||||
int _currentStepIndex = 0;
|
||||
List<String> _userResponses = [];
|
||||
bool _isCompleted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_userResponses = List.filled(widget.scenario.steps.length, '');
|
||||
}
|
||||
|
||||
ScenarioStep get _currentStep => widget.scenario.steps[_currentStepIndex];
|
||||
|
||||
void _selectOption(String option) {
|
||||
setState(() {
|
||||
_userResponses[_currentStepIndex] = option;
|
||||
});
|
||||
}
|
||||
|
||||
void _nextStep() {
|
||||
if (_currentStepIndex < widget.scenario.steps.length - 1) {
|
||||
setState(() {
|
||||
_currentStepIndex++;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isCompleted = true;
|
||||
});
|
||||
_showCompletionDialog();
|
||||
}
|
||||
}
|
||||
|
||||
void _previousStep() {
|
||||
if (_currentStepIndex > 0) {
|
||||
setState(() {
|
||||
_currentStepIndex--;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showCompletionDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('🎉 恭喜完成!'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('您已成功完成「${widget.scenario.title}」场景练习!'),
|
||||
const SizedBox(height: 16),
|
||||
const Text('继续练习可以提高您的英语口语水平。'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(); // 关闭对话框
|
||||
Navigator.of(context).pop(); // 返回主页
|
||||
},
|
||||
child: const Text('返回主页'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(); // 关闭对话框
|
||||
_restartScenario();
|
||||
},
|
||||
child: const Text('重新练习'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _restartScenario() {
|
||||
setState(() {
|
||||
_currentStepIndex = 0;
|
||||
_userResponses = List.filled(widget.scenario.steps.length, '');
|
||||
_isCompleted = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _showScenarioInfo() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
maxChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
builder: (context, scrollController) => Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.scenario.type.icon,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.scenario.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.scenario.subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
widget.scenario.description,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'学习目标',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...widget.scenario.objectives.map(
|
||||
(objective) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle,
|
||||
color: Colors.green, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(objective)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'关键短语',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: widget.scenario.keyPhrases.map(
|
||||
(phrase) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||
),
|
||||
child: Text(
|
||||
phrase,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
),
|
||||
).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.scenario.title,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
'步骤 ${_currentStepIndex + 1}/${widget.scenario.steps.length}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showScenarioInfo,
|
||||
icon: const Icon(Icons.info_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 进度条
|
||||
LinearProgressIndicator(
|
||||
value: (_currentStepIndex + 1) / widget.scenario.steps.length,
|
||||
backgroundColor: Colors.grey[300],
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 步骤标题
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_currentStep.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_currentStep.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 对话内容
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _currentStep.role == 'npc'
|
||||
? Colors.grey[100]
|
||||
: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _currentStep.role == 'npc'
|
||||
? Colors.grey.withOpacity(0.3)
|
||||
: Colors.blue.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: _currentStep.role == 'npc'
|
||||
? Colors.grey
|
||||
: Colors.blue,
|
||||
child: Text(
|
||||
_currentStep.role == 'npc' ? 'NPC' : 'YOU',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_currentStep.role == 'npc' ? '对方说:' : '您说:',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_currentStep.content,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 选项
|
||||
if (_currentStep.options.isNotEmpty) ...[
|
||||
const Text(
|
||||
'请选择您的回应:',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...(_currentStep.options.asMap().entries.map(
|
||||
(entry) {
|
||||
final index = entry.key;
|
||||
final option = entry.value;
|
||||
final isSelected = _userResponses[_currentStepIndex] == option;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => _selectOption(option),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Colors.blue.withOpacity(0.1)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Colors.blue
|
||||
: Colors.grey.withOpacity(0.3),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected
|
||||
? Colors.blue
|
||||
: Colors.grey[300],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
String.fromCharCode(65 + index), // A, B, C
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: Colors.grey[600],
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
option,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isSelected
|
||||
? Colors.blue
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 底部按钮
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_currentStepIndex > 0)
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _previousStep,
|
||||
child: const Text('上一步'),
|
||||
),
|
||||
),
|
||||
if (_currentStepIndex > 0) const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _userResponses[_currentStepIndex].isNotEmpty ||
|
||||
_currentStep.options.isEmpty
|
||||
? _nextStep
|
||||
: null,
|
||||
child: Text(
|
||||
_currentStepIndex == widget.scenario.steps.length - 1
|
||||
? '完成练习'
|
||||
: '下一步',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/speaking_scenario.dart';
|
||||
import '../models/conversation.dart';
|
||||
import '../providers/speaking_provider.dart';
|
||||
|
||||
class SpeakingConversationScreen extends StatefulWidget {
|
||||
final SpeakingTask task;
|
||||
|
||||
const SpeakingConversationScreen({
|
||||
super.key,
|
||||
required this.task,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SpeakingConversationScreen> createState() => _SpeakingConversationScreenState();
|
||||
}
|
||||
|
||||
class _SpeakingConversationScreenState extends State<SpeakingConversationScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
bool _isTextMode = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<SpeakingProvider>().startConversation(widget.task.id);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.task.title),
|
||||
actions: [
|
||||
Consumer<SpeakingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
provider.currentConversation?.status == ConversationStatus.active
|
||||
? Icons.pause
|
||||
: Icons.stop,
|
||||
),
|
||||
onPressed: () {
|
||||
if (provider.currentConversation?.status == ConversationStatus.active) {
|
||||
provider.pauseConversation();
|
||||
} else {
|
||||
_showEndConversationDialog();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<SpeakingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final conversation = provider.currentConversation;
|
||||
|
||||
if (conversation == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 任务信息卡片
|
||||
_buildTaskInfoCard(),
|
||||
|
||||
// 对话状态指示器
|
||||
_buildStatusIndicator(conversation.status),
|
||||
|
||||
// 消息列表
|
||||
Expanded(
|
||||
child: _buildMessageList(conversation.messages),
|
||||
),
|
||||
|
||||
// 输入区域
|
||||
_buildInputArea(provider),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskInfoCard() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat,
|
||||
color: Theme.of(context).primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.task.scenario.displayName,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.task.difficulty.displayName,
|
||||
style: TextStyle(
|
||||
color: Colors.blue,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.task.description,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[700],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (widget.task.objectives.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'目标: ${widget.task.objectives.join('、')}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIndicator(ConversationStatus status) {
|
||||
Color statusColor;
|
||||
String statusText;
|
||||
IconData statusIcon;
|
||||
|
||||
switch (status) {
|
||||
case ConversationStatus.active:
|
||||
statusColor = Colors.green;
|
||||
statusText = '对话进行中';
|
||||
statusIcon = Icons.mic;
|
||||
break;
|
||||
case ConversationStatus.paused:
|
||||
statusColor = Colors.orange;
|
||||
statusText = '对话已暂停';
|
||||
statusIcon = Icons.pause;
|
||||
break;
|
||||
case ConversationStatus.completed:
|
||||
statusColor = Colors.blue;
|
||||
statusText = '对话已完成';
|
||||
statusIcon = Icons.check_circle;
|
||||
break;
|
||||
case ConversationStatus.cancelled:
|
||||
statusColor = Colors.red;
|
||||
statusText = '对话已取消';
|
||||
statusIcon = Icons.cancel;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: statusColor.withOpacity(0.1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
statusIcon,
|
||||
color: statusColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageList(List<ConversationMessage> messages) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
return _buildMessageBubble(message);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(ConversationMessage message) {
|
||||
final isUser = message.type == MessageType.user;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
child: const Icon(
|
||||
Icons.smart_toy,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(18).copyWith(
|
||||
bottomLeft: isUser ? const Radius.circular(18) : const Radius.circular(4),
|
||||
bottomRight: isUser ? const Radius.circular(4) : const Radius.circular(18),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
message.content,
|
||||
style: TextStyle(
|
||||
color: isUser ? Colors.white : Colors.black87,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (message.audioUrl != null)
|
||||
Icon(
|
||||
Icons.volume_up,
|
||||
size: 12,
|
||||
color: isUser ? Colors.white70 : Colors.grey[600],
|
||||
),
|
||||
if (message.audioUrl != null) const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
color: isUser ? Colors.white70 : Colors.grey[600],
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
if (message.confidence != null && isUser) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.mic,
|
||||
size: 12,
|
||||
color: _getConfidenceColor(message.confidence!),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${(message.confidence! * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
color: _getConfidenceColor(message.confidence!),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
const SizedBox(width: 8),
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.grey[300],
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: Colors.grey[600],
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputArea(SpeakingProvider provider) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 模式切换
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
label: Text('语音'),
|
||||
icon: Icon(Icons.mic),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
label: Text('文字'),
|
||||
icon: Icon(Icons.keyboard),
|
||||
),
|
||||
],
|
||||
selected: {_isTextMode},
|
||||
onSelectionChanged: (Set<bool> selection) {
|
||||
setState(() {
|
||||
_isTextMode = selection.first;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 输入控件
|
||||
if (_isTextMode)
|
||||
_buildTextInput(provider)
|
||||
else
|
||||
_buildVoiceInput(provider),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextInput(SpeakingProvider provider) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入你的回复...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (text) => _sendTextMessage(provider, text),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FloatingActionButton(
|
||||
mini: true,
|
||||
onPressed: () => _sendTextMessage(provider, _textController.text),
|
||||
child: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVoiceInput(SpeakingProvider provider) {
|
||||
return Column(
|
||||
children: [
|
||||
// 录音按钮
|
||||
GestureDetector(
|
||||
onTapDown: (_) => provider.startRecording(),
|
||||
onTapUp: (_) => provider.stopRecording(),
|
||||
onTapCancel: () => provider.stopRecording(),
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: provider.isRecording
|
||||
? Colors.red
|
||||
: Theme.of(context).primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (provider.isRecording ? Colors.red : Theme.of(context).primaryColor)
|
||||
.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
spreadRadius: provider.isRecording ? 5 : 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
provider.isRecording ? Icons.stop : Icons.mic,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 录音提示
|
||||
Text(
|
||||
provider.isRecording ? '松开发送' : '按住说话',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
|
||||
// 录音时长
|
||||
if (provider.isRecording)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
_formatRecordingDuration(const Duration(seconds: 0)),
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _sendTextMessage(SpeakingProvider provider, String text) {
|
||||
if (text.trim().isEmpty) return;
|
||||
|
||||
provider.sendMessage(text.trim());
|
||||
_textController.clear();
|
||||
}
|
||||
|
||||
void _showEndConversationDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('结束对话'),
|
||||
content: const Text('确定要结束当前对话吗?对话记录将被保存。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
context.read<SpeakingProvider>().endConversation();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatRecordingDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Color _getConfidenceColor(double confidence) {
|
||||
if (confidence >= 0.8) {
|
||||
return Colors.green;
|
||||
} else if (confidence >= 0.6) {
|
||||
return Colors.orange;
|
||||
} else {
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/conversation.dart';
|
||||
import '../models/speaking_scenario.dart';
|
||||
import '../providers/speaking_provider.dart';
|
||||
|
||||
class SpeakingHistoryScreen extends StatefulWidget {
|
||||
const SpeakingHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SpeakingHistoryScreen> createState() => _SpeakingHistoryScreenState();
|
||||
}
|
||||
|
||||
class _SpeakingHistoryScreenState extends State<SpeakingHistoryScreen> {
|
||||
String _selectedFilter = 'all';
|
||||
String _searchQuery = '';
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
List<Conversation> _conversations = [];
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadConversations();
|
||||
}
|
||||
|
||||
Future<void> _loadConversations() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final conversations = await context.read<SpeakingProvider>().loadConversationHistory();
|
||||
setState(() {
|
||||
_conversations = conversations;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('对话历史'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: _showSearchDialog,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 筛选器
|
||||
_buildFilterBar(),
|
||||
|
||||
// 历史列表
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _buildErrorState(_error!, _loadConversations)
|
||||
: _conversations.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildHistoryList(_filterHistory(_conversations)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip('all', '全部'),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip('completed', '已完成'),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip('today', '今天'),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip('week', '本周'),
|
||||
const SizedBox(width: 8),
|
||||
_buildFilterChip('month', '本月'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_searchQuery.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
_searchController.clear();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip(String value, String label) {
|
||||
final isSelected = _selectedFilter == value;
|
||||
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_selectedFilter = value;
|
||||
});
|
||||
},
|
||||
selectedColor: Theme.of(context).primaryColor.withOpacity(0.2),
|
||||
checkmarkColor: Theme.of(context).primaryColor,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryList(List<Conversation> conversations) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: conversations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final conversation = conversations[index];
|
||||
return _buildConversationCard(conversation);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConversationCard(Conversation conversation) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _showConversationDetail(conversation),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题行
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(conversation.status).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
_getStatusIcon(conversation.status),
|
||||
color: _getStatusColor(conversation.status),
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'对话练习',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatDateTime(conversation.startTime),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 统计信息
|
||||
Row(
|
||||
children: [
|
||||
_buildStatChip(
|
||||
Icons.chat_bubble_outline,
|
||||
'${conversation.messages.length} 条消息',
|
||||
Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildStatChip(
|
||||
Icons.access_time,
|
||||
_formatDuration(conversation.totalDuration ~/ 60),
|
||||
Colors.green,
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
|
||||
// 最后一条消息预览
|
||||
if (conversation.messages.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
conversation.messages.last.type == MessageType.user
|
||||
? Icons.person
|
||||
: Icons.smart_toy,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
conversation.messages.last.content,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatChip(IconData icon, String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 12,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
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 _buildErrorState(String error, VoidCallback? retry) {
|
||||
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: 18,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (retry != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: ElevatedButton(
|
||||
onPressed: retry,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Conversation> _filterHistory(List<Conversation> conversations) {
|
||||
var filtered = conversations;
|
||||
|
||||
// 按状态筛选
|
||||
if (_selectedFilter == 'completed') {
|
||||
filtered = filtered.where((c) => c.status == ConversationStatus.completed).toList();
|
||||
}
|
||||
|
||||
// 按时间筛选
|
||||
final now = DateTime.now();
|
||||
if (_selectedFilter == 'today') {
|
||||
filtered = filtered.where((c) {
|
||||
final startOfDay = DateTime(now.year, now.month, now.day);
|
||||
return c.startTime.isAfter(startOfDay);
|
||||
}).toList();
|
||||
} else if (_selectedFilter == 'week') {
|
||||
final startOfWeek = now.subtract(Duration(days: now.weekday - 1));
|
||||
final startOfWeekDay = DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day);
|
||||
filtered = filtered.where((c) => c.startTime.isAfter(startOfWeekDay)).toList();
|
||||
} else if (_selectedFilter == 'month') {
|
||||
final startOfMonth = DateTime(now.year, now.month, 1);
|
||||
filtered = filtered.where((c) => c.startTime.isAfter(startOfMonth)).toList();
|
||||
}
|
||||
|
||||
// 按搜索关键词筛选
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
filtered = filtered.where((c) {
|
||||
final query = _searchQuery.toLowerCase();
|
||||
return c.messages.any((m) => m.content.toLowerCase().contains(query));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// 按时间倒序排列
|
||||
filtered.sort((a, b) => b.startTime.compareTo(a.startTime));
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
void _showSearchDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('搜索对话'),
|
||||
content: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入关键词...',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
onSubmitted: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchQuery = _searchController.text;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('搜索'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showConversationDetail(Conversation conversation) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
maxChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 拖拽指示器
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
'对话详情',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 基本信息
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatDateTime(conversation.startTime),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
_formatDuration(conversation.totalDuration ~/ 60),
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 消息列表
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: conversation.messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = conversation.messages[index];
|
||||
return _buildMessageItem(message);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageItem(ConversationMessage message) {
|
||||
final isUser = message.type == MessageType.user;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
child: const Icon(
|
||||
Icons.smart_toy,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(12).copyWith(
|
||||
bottomLeft: isUser ? const Radius.circular(12) : const Radius.circular(4),
|
||||
bottomRight: isUser ? const Radius.circular(4) : const Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
message.content,
|
||||
style: TextStyle(
|
||||
color: isUser ? Colors.white : Colors.black87,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
color: isUser ? Colors.white70 : Colors.grey[600],
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUser)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.grey[300],
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: Colors.grey[600],
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getStatusColor(ConversationStatus status) {
|
||||
switch (status) {
|
||||
case ConversationStatus.active:
|
||||
return Colors.green;
|
||||
case ConversationStatus.paused:
|
||||
return Colors.orange;
|
||||
case ConversationStatus.completed:
|
||||
return Colors.blue;
|
||||
case ConversationStatus.cancelled:
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getStatusIcon(ConversationStatus status) {
|
||||
switch (status) {
|
||||
case ConversationStatus.active:
|
||||
return Icons.play_circle;
|
||||
case ConversationStatus.paused:
|
||||
return Icons.pause_circle;
|
||||
case ConversationStatus.completed:
|
||||
return Icons.check_circle;
|
||||
case ConversationStatus.cancelled:
|
||||
return Icons.cancel;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inDays == 0) {
|
||||
return '今天 ${_formatTime(dateTime)}';
|
||||
} else if (difference.inDays == 1) {
|
||||
return '昨天 ${_formatTime(dateTime)}';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays}天前';
|
||||
} else {
|
||||
return '${dateTime.month}/${dateTime.day} ${_formatTime(dateTime)}';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatDuration(int minutes) {
|
||||
if (minutes < 60) {
|
||||
return '${minutes}分钟';
|
||||
} else {
|
||||
final hours = minutes ~/ 60;
|
||||
final remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes == 0) {
|
||||
return '${hours}小时';
|
||||
} else {
|
||||
return '${hours}小时${remainingMinutes}分钟';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
565
client/lib/features/speaking/screens/speaking_home_screen.dart
Normal file
565
client/lib/features/speaking/screens/speaking_home_screen.dart
Normal file
@@ -0,0 +1,565 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/ai_tutor.dart';
|
||||
import '../data/ai_tutor_data.dart';
|
||||
import '../screens/ai_conversation_screen.dart';
|
||||
import '../models/conversation_scenario.dart';
|
||||
import '../data/scenario_data.dart';
|
||||
import '../providers/speaking_provider.dart';
|
||||
import '../screens/scenario_practice_screen.dart';
|
||||
import '../models/pronunciation_item.dart';
|
||||
import '../screens/pronunciation_list_screen.dart';
|
||||
import '../models/pronunciation_assessment.dart';
|
||||
|
||||
/// 口语练习主页面
|
||||
class SpeakingHomeScreen extends ConsumerStatefulWidget {
|
||||
const SpeakingHomeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SpeakingHomeScreen> createState() => _SpeakingHomeScreenState();
|
||||
}
|
||||
|
||||
class _SpeakingHomeScreenState extends ConsumerState<SpeakingHomeScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 加载推荐场景
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(speakingTasksProvider.notifier).loadRecommendedTasks();
|
||||
});
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black87),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'口语练习',
|
||||
style: TextStyle(
|
||||
color: Colors.black87,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildAITutors(),
|
||||
const SizedBox(height: 20),
|
||||
_buildScenarios(),
|
||||
const SizedBox(height: 20),
|
||||
_buildPronunciationPractice(),
|
||||
const SizedBox(height: 20),
|
||||
_buildSpeakingProgress(),
|
||||
const SizedBox(height: 100), // 底部导航栏空间
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAITutors() {
|
||||
return 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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'AI对话伙伴',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemCount: AITutorData.getAllTutors().length,
|
||||
itemBuilder: (context, index) {
|
||||
final tutor = AITutorData.getAllTutors()[index];
|
||||
return _buildTutorCard(tutor);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTutorCard(AITutor tutor) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AIConversationScreen(tutor: tutor),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: tutor.type.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: tutor.type.color.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
tutor.avatar,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
tutor.type.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: tutor.type.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
tutor.type.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScenarios() {
|
||||
final tasksState = ref.watch(speakingTasksProvider);
|
||||
|
||||
return 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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'对话场景',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
tasksState.isLoading
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: tasksState.error != null
|
||||
? Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'加载失败: ${tasksState.error}',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(speakingTasksProvider.notifier).loadRecommendedTasks();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: tasksState.tasks.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'暂无可用场景',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'使用静态数据作为备选',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: tasksState.tasks.take(5).map((task) {
|
||||
// 将SpeakingTask转换为ConversationScenario显示
|
||||
final scenario = ConversationScenario(
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
subtitle: task.description,
|
||||
description: task.description,
|
||||
duration: '${task.estimatedDuration}分钟',
|
||||
level: _mapDifficultyToLevel(task.difficulty.name),
|
||||
type: ScenarioType.business,
|
||||
objectives: task.objectives,
|
||||
keyPhrases: task.keyPhrases,
|
||||
steps: [],
|
||||
createdAt: task.createdAt,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildScenarioItem(scenario),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScenarioItem(ConversationScenario scenario) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ScenarioPracticeScreen(scenario: scenario),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
scenario.type.icon,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
scenario.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
scenario.subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
scenario.duration,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2196F3),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
scenario.level,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPronunciationPractice() {
|
||||
return 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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'发音练习',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPronunciationCard(
|
||||
'单词发音',
|
||||
'音素练习',
|
||||
Icons.hearing,
|
||||
Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildPronunciationCard(
|
||||
'句子朗读',
|
||||
'语调练习',
|
||||
Icons.graphic_eq,
|
||||
Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPronunciationCard(
|
||||
String title,
|
||||
String subtitle,
|
||||
IconData icon,
|
||||
Color color,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
PronunciationType type;
|
||||
if (title == '单词发音') {
|
||||
type = PronunciationType.word;
|
||||
} else {
|
||||
type = PronunciationType.sentence;
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PronunciationListScreen(type: type),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpeakingProgress() {
|
||||
return 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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'口语统计',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final service = ref.watch(speakingServiceProvider);
|
||||
return FutureBuilder(
|
||||
future: service.getUserSpeakingStatistics(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final stats = snapshot.data!.data;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildProgressItem('${stats?.totalSessions ?? 0}', '对话次数', Icons.chat_bubble),
|
||||
_buildProgressItem('${stats != null ? stats.skillAnalysis.criteriaScores[PronunciationCriteria.accuracy]?.toStringAsFixed(0) ?? '0' : '0'}%', '发音准确度', Icons.mic),
|
||||
_buildProgressItem('${stats?.averageScore.toStringAsFixed(0) ?? '0'}', '平均分', Icons.trending_up),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressItem(String value, String label, IconData icon) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: const Color(0xFF2196F3),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF2196F3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _mapDifficultyToLevel(String? difficulty) {
|
||||
if (difficulty == null) return 'B1';
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'beginner':
|
||||
case 'elementary':
|
||||
return 'A1';
|
||||
case 'intermediate':
|
||||
return 'B1';
|
||||
case 'upper-intermediate':
|
||||
case 'upperintermediate':
|
||||
return 'B2';
|
||||
case 'advanced':
|
||||
return 'C1';
|
||||
default:
|
||||
return 'B1';
|
||||
}
|
||||
}
|
||||
}
|
||||
746
client/lib/features/speaking/screens/speaking_result_screen.dart
Normal file
746
client/lib/features/speaking/screens/speaking_result_screen.dart
Normal file
@@ -0,0 +1,746 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/speaking_provider.dart';
|
||||
|
||||
class SpeakingResultScreen extends StatefulWidget {
|
||||
final Map<String, dynamic> evaluation;
|
||||
final String conversationId;
|
||||
|
||||
const SpeakingResultScreen({
|
||||
super.key,
|
||||
required this.evaluation,
|
||||
required this.conversationId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SpeakingResultScreen> createState() => _SpeakingResultScreenState();
|
||||
}
|
||||
|
||||
class _SpeakingResultScreenState extends State<SpeakingResultScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
bool _showDetails = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.3),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('练习结果'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: _shareResult,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: _buildContent(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: _buildBottomActions(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 总分卡片
|
||||
_buildOverallScoreCard(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 详细评分
|
||||
_buildDetailedScores(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 反馈和建议
|
||||
_buildFeedbackSection(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 进步对比
|
||||
_buildProgressComparison(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 录音回放
|
||||
_buildAudioPlayback(),
|
||||
const SizedBox(height: 100), // 为底部按钮留空间
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverallScoreCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
_getScoreColor(widget.evaluation['overallScore'] as double? ?? 0.0),
|
||||
_getScoreColor(widget.evaluation['overallScore'] as double? ?? 0.0).withOpacity(0.8),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _getScoreColor(widget.evaluation['overallScore'] as double? ?? 0.0).withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'总体评分',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${(widget.evaluation['overallScore'] as double? ?? 0.0).toStringAsFixed(1)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_getScoreLevel(widget.evaluation['overallScore'] as double? ?? 0.0),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_getScoreIcon(widget.evaluation['overallScore'] as double? ?? 0.0),
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_getScoreMessage(widget.evaluation['overallScore'] as double? ?? 0.0),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailedScores() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'详细评分',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showDetails = !_showDetails;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
_showDetails ? '收起' : '展开',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...(widget.evaluation['criteriaScores'] as Map<String, double>? ?? {}).entries.map(
|
||||
(entry) => _buildScoreItem(
|
||||
_getCriteriaDisplayName(entry.key.toString()),
|
||||
entry.value,
|
||||
),
|
||||
),
|
||||
if (_showDetails) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
_buildDetailedAnalysis(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScoreItem(String title, double score) {
|
||||
final percentage = score / 100;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${score.toStringAsFixed(1)}分',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getScoreColor(score),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: percentage,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_getScoreColor(score),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailedAnalysis() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'详细分析',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if ((widget.evaluation['strengths'] as List<String>? ?? []).isNotEmpty) ...[
|
||||
_buildAnalysisSection(
|
||||
'表现优秀',
|
||||
widget.evaluation['strengths'] as List<String>? ?? [],
|
||||
Colors.green,
|
||||
Icons.check_circle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if ((widget.evaluation['weaknesses'] as List<String>? ?? []).isNotEmpty) ...[
|
||||
_buildAnalysisSection(
|
||||
'需要改进',
|
||||
widget.evaluation['weaknesses'] as List<String>? ?? [],
|
||||
Colors.orange,
|
||||
Icons.warning,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if ((widget.evaluation['commonErrors'] as List<String>? ?? []).isNotEmpty)
|
||||
_buildAnalysisSection(
|
||||
'常见错误',
|
||||
widget.evaluation['commonErrors'] as List<String>? ?? [],
|
||||
Colors.red,
|
||||
Icons.error,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnalysisSection(
|
||||
String title,
|
||||
List<String> items,
|
||||
Color color,
|
||||
IconData icon,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...items.map((item) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeedbackSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'改进建议',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if ((widget.evaluation['suggestions'] as List<String>? ?? []).isNotEmpty)
|
||||
...(widget.evaluation['suggestions'] as List<String>? ?? []).map(
|
||||
(suggestion) => _buildSuggestionItem(suggestion),
|
||||
)
|
||||
else
|
||||
const Text(
|
||||
'表现很好,继续保持!',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestionItem(String suggestion) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.blue[200]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.lightbulb_outline,
|
||||
color: Colors.blue[600],
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue[800],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressComparison() {
|
||||
return Consumer<SpeakingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final previousScore = _getPreviousScore(provider);
|
||||
if (previousScore == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final improvement = (widget.evaluation['overallScore'] as double? ?? 0.0) - previousScore;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'进步对比',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildComparisonItem(
|
||||
'上次得分',
|
||||
previousScore.toStringAsFixed(1),
|
||||
Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildComparisonItem(
|
||||
'本次得分',
|
||||
(widget.evaluation['overallScore'] as double? ?? 0.0).toStringAsFixed(1),
|
||||
_getScoreColor(widget.evaluation['overallScore'] as double? ?? 0.0),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildComparisonItem(
|
||||
'进步幅度',
|
||||
'${improvement >= 0 ? '+' : ''}${improvement.toStringAsFixed(1)}',
|
||||
improvement >= 0 ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildComparisonItem(String title, String value, Color color) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAudioPlayback() {
|
||||
if (widget.evaluation['audioUrl'] == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
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: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'录音回放',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _playAudio,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'点击播放你的录音',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'时长: ${_formatDuration(Duration(seconds: widget.evaluation['duration'] as int? ?? 0))}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _downloadAudio,
|
||||
icon: const Icon(Icons.download),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActions() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _practiceAgain,
|
||||
child: const Text('再次练习'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _continueNext,
|
||||
child: const Text('继续下一个'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 辅助方法
|
||||
Color _getScoreColor(double score) {
|
||||
if (score >= 90) return Colors.green;
|
||||
if (score >= 80) return Colors.blue;
|
||||
if (score >= 70) return Colors.orange;
|
||||
return Colors.red;
|
||||
}
|
||||
|
||||
String _getScoreLevel(double score) {
|
||||
if (score >= 90) return '优秀';
|
||||
if (score >= 80) return '良好';
|
||||
if (score >= 70) return '一般';
|
||||
return '需要改进';
|
||||
}
|
||||
|
||||
IconData _getScoreIcon(double score) {
|
||||
if (score >= 90) return Icons.emoji_events;
|
||||
if (score >= 80) return Icons.thumb_up;
|
||||
if (score >= 70) return Icons.trending_up;
|
||||
return Icons.trending_down;
|
||||
}
|
||||
|
||||
String _getScoreMessage(double score) {
|
||||
if (score >= 90) return '表现出色!';
|
||||
if (score >= 80) return '表现良好!';
|
||||
if (score >= 70) return '继续努力!';
|
||||
return '需要更多练习';
|
||||
}
|
||||
|
||||
String _getCriteriaDisplayName(String criteria) {
|
||||
const criteriaNames = {
|
||||
'pronunciation': '发音',
|
||||
'fluency': '流利度',
|
||||
'grammar': '语法',
|
||||
'vocabulary': '词汇',
|
||||
'comprehension': '理解力',
|
||||
};
|
||||
return criteriaNames[criteria] ?? criteria;
|
||||
}
|
||||
|
||||
double? _getPreviousScore(SpeakingProvider provider) {
|
||||
// 简化实现,实际应该从历史记录中获取
|
||||
return 75.0;
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _shareResult() {
|
||||
// 实现分享功能
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('分享功能开发中...')),
|
||||
);
|
||||
}
|
||||
|
||||
void _playAudio() {
|
||||
// 实现音频播放
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('播放录音...')),
|
||||
);
|
||||
}
|
||||
|
||||
void _downloadAudio() {
|
||||
// 实现音频下载
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('下载录音...')),
|
||||
);
|
||||
}
|
||||
|
||||
void _practiceAgain() {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
void _continueNext() {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop();
|
||||
// 可以导航到下一个练习
|
||||
}
|
||||
}
|
||||
449
client/lib/features/speaking/screens/speaking_screen.dart
Normal file
449
client/lib/features/speaking/screens/speaking_screen.dart
Normal file
@@ -0,0 +1,449 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/speaking_provider.dart';
|
||||
import '../models/speaking_scenario.dart';
|
||||
import 'speaking_conversation_screen.dart';
|
||||
import 'speaking_history_screen.dart';
|
||||
import 'speaking_stats_screen.dart';
|
||||
|
||||
class SpeakingScreen extends StatefulWidget {
|
||||
const SpeakingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SpeakingScreen> createState() => _SpeakingScreenState();
|
||||
}
|
||||
|
||||
class _SpeakingScreenState extends State<SpeakingScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
String _selectedDifficulty = 'all';
|
||||
String _selectedScenario = 'all';
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<SpeakingProvider>().loadTasks();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('口语练习'),
|
||||
backgroundColor: Colors.blue.shade50,
|
||||
foregroundColor: Colors.blue.shade800,
|
||||
elevation: 0,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: Colors.blue.shade800,
|
||||
unselectedLabelColor: Colors.grey,
|
||||
indicatorColor: Colors.blue.shade600,
|
||||
tabs: const [
|
||||
Tab(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
text: '练习',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.history),
|
||||
text: '历史',
|
||||
),
|
||||
Tab(
|
||||
icon: Icon(Icons.analytics_outlined),
|
||||
text: '统计',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildPracticeTab(),
|
||||
const SpeakingHistoryScreen(),
|
||||
const SpeakingStatsScreen(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPracticeTab() {
|
||||
return Consumer<SpeakingProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
provider.error!,
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => provider.loadTasks(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final filteredTasks = _filterTasks(provider.tasks);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildFilterSection(),
|
||||
Expanded(
|
||||
child: filteredTasks.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildTaskList(filteredTasks),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.shade200,
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 搜索框
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索练习内容...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 筛选器
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildFilterDropdown(
|
||||
'难度',
|
||||
_selectedDifficulty,
|
||||
[
|
||||
{'value': 'all', 'label': '全部'},
|
||||
{'value': 'beginner', 'label': '初级'},
|
||||
{'value': 'intermediate', 'label': '中级'},
|
||||
{'value': 'advanced', 'label': '高级'},
|
||||
],
|
||||
(value) {
|
||||
setState(() {
|
||||
_selectedDifficulty = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildFilterDropdown(
|
||||
'场景',
|
||||
_selectedScenario,
|
||||
[
|
||||
{'value': 'all', 'label': '全部'},
|
||||
{'value': 'dailyConversation', 'label': '日常对话'},
|
||||
{'value': 'businessMeeting', 'label': '商务会议'},
|
||||
{'value': 'travel', 'label': '旅行'},
|
||||
{'value': 'academic', 'label': '学术讨论'},
|
||||
],
|
||||
(value) {
|
||||
setState(() {
|
||||
_selectedScenario = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterDropdown(
|
||||
String label,
|
||||
String value,
|
||||
List<Map<String, String>> options,
|
||||
Function(String) onChanged,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
isExpanded: true,
|
||||
hint: Text(label),
|
||||
items: options.map((option) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: option['value'],
|
||||
child: Text(option['label']!),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (newValue) {
|
||||
if (newValue != null) {
|
||||
onChanged(newValue);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 64,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无练习内容',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'请尝试调整筛选条件',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskList(List<SpeakingTask> tasks) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: tasks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final task = tasks[index];
|
||||
return _buildTaskCard(task);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskCard(SpeakingTask task) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _startTask(task),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.chat,
|
||||
color: Colors.blue.shade600,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
task.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
task.description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_buildTaskTag(
|
||||
_getDifficultyLabel(task.difficulty),
|
||||
Colors.blue,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildTaskTag(
|
||||
_getScenarioLabel(task.scenario),
|
||||
Colors.green,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${task.estimatedDuration}分钟',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskTag(String label, Color color) {
|
||||
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.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<SpeakingTask> _filterTasks(List<SpeakingTask> tasks) {
|
||||
return tasks.where((task) {
|
||||
// 搜索过滤
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final query = _searchQuery.toLowerCase();
|
||||
if (!task.title.toLowerCase().contains(query) &&
|
||||
!task.description.toLowerCase().contains(query)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 难度过滤
|
||||
if (_selectedDifficulty != 'all' &&
|
||||
task.difficulty.toString().split('.').last != _selectedDifficulty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 场景过滤
|
||||
if (_selectedScenario != 'all' &&
|
||||
task.scenario.toString().split('.').last != _selectedScenario) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _getDifficultyLabel(SpeakingDifficulty difficulty) {
|
||||
return difficulty.displayName;
|
||||
}
|
||||
|
||||
String _getScenarioLabel(SpeakingScenario scenario) {
|
||||
return scenario.displayName;
|
||||
}
|
||||
|
||||
void _startTask(SpeakingTask task) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SpeakingConversationScreen(task: task),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1140
client/lib/features/speaking/screens/speaking_stats_screen.dart
Normal file
1140
client/lib/features/speaking/screens/speaking_stats_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user