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

View File

@@ -0,0 +1,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();
}
}

View File

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

View File

@@ -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('去设置'),
),
],
),
);
}
}

View File

@@ -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
? '完成练习'
: '下一步',
),
),
),
],
),
),
],
),
);
}
}

View File

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

View File

@@ -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}分钟';
}
}
}
}

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

View 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();
// 可以导航到下一个练习
}
}

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

File diff suppressed because it is too large Load Diff