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,139 @@
import '../models/ai_tutor.dart';
/// AI导师静态数据
class AITutorData {
static final List<AITutor> _tutors = [
AITutor(
id: 'tutor_business_001',
type: TutorType.business,
name: 'Emma Wilson',
avatar: '👩‍💼',
introduction: '我是Emma拥有10年国际商务经验的专业导师。我将帮助你掌握商务英语提升职场沟通能力。',
specialties: [
'商务会议主持',
'产品演示与推介',
'商务谈判技巧',
'邮件沟通礼仪',
'客户关系维护',
'团队协作沟通',
],
sampleQuestions: [
'Could you tell me about your company\'s main products?',
'What\'s your strategy for the next quarter?',
'How do you handle difficult clients?',
'Can you walk me through your proposal?',
'What are the key benefits of this solution?',
],
personality: '专业、严谨、富有耐心,善于引导学员进行深度商务对话',
createdAt: DateTime.now().subtract(const Duration(days: 30)),
updatedAt: DateTime.now().subtract(const Duration(days: 1)),
),
AITutor(
id: 'tutor_daily_001',
type: TutorType.daily,
name: 'Mike Johnson',
avatar: '👨‍🦱',
introduction: '我是Mike一个热爱生活的英语导师。让我们一起在轻松愉快的氛围中提升你的日常英语交流能力',
specialties: [
'日常生活对话',
'购物与消费',
'餐厅用餐',
'交通出行',
'社交聚会',
'兴趣爱好分享',
],
sampleQuestions: [
'What did you do over the weekend?',
'How was your day today?',
'What\'s your favorite type of food?',
'Do you have any hobbies?',
'What\'s the weather like today?',
],
personality: '友好、幽默、平易近人,善于创造轻松的对话氛围',
createdAt: DateTime.now().subtract(const Duration(days: 25)),
updatedAt: DateTime.now().subtract(const Duration(days: 1)),
),
AITutor(
id: 'tutor_travel_001',
type: TutorType.travel,
name: 'Sarah Chen',
avatar: '👩‍✈️',
introduction: '我是Sarah环游过50多个国家的旅行达人。我会教你在世界各地都能自信交流的旅行英语',
specialties: [
'机场办理手续',
'酒店预订入住',
'问路与导航',
'景点游览',
'紧急情况处理',
'文化交流',
],
sampleQuestions: [
'Where would you like to go for your next trip?',
'Have you ever been to any foreign countries?',
'What\'s your favorite travel destination?',
'How do you usually plan your trips?',
'What would you do if you got lost in a foreign city?',
],
personality: '热情、冒险、见多识广,能分享丰富的旅行经验',
createdAt: DateTime.now().subtract(const Duration(days: 20)),
updatedAt: DateTime.now().subtract(const Duration(days: 1)),
),
AITutor(
id: 'tutor_academic_001',
type: TutorType.academic,
name: 'Dr. Robert Smith',
avatar: '👨‍🎓',
introduction: '我是Robert博士在学术界工作了15年。我将帮助你掌握学术英语提升演讲和论文写作能力。',
specialties: [
'学术演讲技巧',
'论文讨论',
'研究方法论述',
'学术会议参与',
'同行评议',
'批判性思维表达',
],
sampleQuestions: [
'What\'s your research focus?',
'How would you approach this research problem?',
'What are the limitations of this study?',
'Can you explain your methodology?',
'What are the implications of these findings?',
],
personality: '博学、严谨、启发性强,善于引导深度学术思考',
createdAt: DateTime.now().subtract(const Duration(days: 15)),
updatedAt: DateTime.now().subtract(const Duration(days: 1)),
),
];
/// 获取所有AI导师
static List<AITutor> getAllTutors() {
return List.unmodifiable(_tutors);
}
/// 根据ID获取AI导师
static AITutor? getTutorById(String id) {
try {
return _tutors.firstWhere((tutor) => tutor.id == id);
} catch (e) {
return null;
}
}
/// 根据类型获取AI导师
static AITutor? getTutorByType(TutorType type) {
try {
return _tutors.firstWhere((tutor) => tutor.type == type);
} catch (e) {
return null;
}
}
/// 获取推荐的AI导师
static List<AITutor> getRecommendedTutors() {
// 返回所有导师,实际应用中可以根据用户偏好推荐
return getAllTutors();
}
}

View File

@@ -0,0 +1,304 @@
import '../models/pronunciation_item.dart';
/// 发音练习静态数据
class PronunciationData {
static final List<PronunciationItem> _wordPronunciation = [
PronunciationItem(
id: 'word_001',
text: 'pronunciation',
phonetic: '/prəˌnʌnsiˈeɪʃn/',
audioUrl: 'assets/audio/pronunciation.mp3',
type: PronunciationType.word,
difficulty: DifficultyLevel.intermediate,
category: '学习词汇',
tips: [
'注意重音在第四个音节',
'末尾的-tion读作/ʃn/',
'中间的nun要清晰发音'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'word_002',
text: 'communication',
phonetic: '/kəˌmjuːnɪˈkeɪʃn/',
audioUrl: 'assets/audio/communication.mp3',
type: PronunciationType.word,
difficulty: DifficultyLevel.intermediate,
category: '商务词汇',
tips: [
'重音在第五个音节',
'注意/mju:/的发音',
'末尾-tion读作/ʃn/'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'word_003',
text: 'restaurant',
phonetic: '/ˈrestərɑːnt/',
audioUrl: 'assets/audio/restaurant.mp3',
type: PronunciationType.word,
difficulty: DifficultyLevel.beginner,
category: '日常词汇',
tips: [
'重音在第一个音节',
'注意/r/音的发音',
'末尾的t通常不发音'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'word_004',
text: 'schedule',
phonetic: '/ˈʃedjuːl/',
audioUrl: 'assets/audio/schedule.mp3',
type: PronunciationType.word,
difficulty: DifficultyLevel.intermediate,
category: '工作词汇',
tips: [
'英式发音以/ʃ/开头',
'美式发音以/sk/开头',
'重音在第一个音节'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'word_005',
text: 'comfortable',
phonetic: '/ˈkʌmftəbl/',
audioUrl: 'assets/audio/comfortable.mp3',
type: PronunciationType.word,
difficulty: DifficultyLevel.advanced,
category: '形容词',
tips: [
'注意弱读音节',
'中间的or通常省略',
'末尾的-able读作/əbl/'
],
createdAt: DateTime.now(),
),
];
static final List<PronunciationItem> _sentencePronunciation = [
PronunciationItem(
id: 'sentence_001',
text: 'How are you doing today?',
phonetic: '/haʊ ɑːr juː ˈduːɪŋ təˈdeɪ/',
audioUrl: 'assets/audio/how_are_you.mp3',
type: PronunciationType.sentence,
difficulty: DifficultyLevel.beginner,
category: '日常问候',
tips: [
'注意连读How are → /haʊər/',
'语调上扬表示疑问',
'today重音在第二个音节'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'sentence_002',
text: 'Could you please help me with this?',
phonetic: '/kʊd juː pliːz help miː wɪð ðɪs/',
audioUrl: 'assets/audio/could_you_help.mp3',
type: PronunciationType.sentence,
difficulty: DifficultyLevel.intermediate,
category: '请求帮助',
tips: [
'礼貌的语调,温和下降',
'注意could的弱读',
'with this连读'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'sentence_003',
text: 'I would like to make a reservation.',
phonetic: '/aɪ wʊd laɪk tuː meɪk ə ˌrezərˈveɪʃn/',
audioUrl: 'assets/audio/reservation.mp3',
type: PronunciationType.sentence,
difficulty: DifficultyLevel.intermediate,
category: '预订服务',
tips: [
'正式语调,清晰发音',
'would like连读',
'reservation重音在第三个音节'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'sentence_004',
text: 'The weather is absolutely beautiful today.',
phonetic: '/ðə ˈweðər ɪz ˌæbsəˈluːtli ˈbjuːtɪfl təˈdeɪ/',
audioUrl: 'assets/audio/weather_beautiful.mp3',
type: PronunciationType.sentence,
difficulty: DifficultyLevel.advanced,
category: '天气描述',
tips: [
'注意节奏和重音分布',
'absolutely的重音模式',
'形容词的语调变化'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'sentence_005',
text: 'I am looking forward to hearing from you.',
phonetic: '/aɪ æm ˈlʊkɪŋ ˈːrwərd tuː ˈhɪrɪŋ frəm juː/',
audioUrl: 'assets/audio/looking_forward.mp3',
type: PronunciationType.sentence,
difficulty: DifficultyLevel.advanced,
category: '商务表达',
tips: [
'正式商务语调',
'注意短语的完整性',
'forward to连读'
],
createdAt: DateTime.now(),
),
];
static final List<PronunciationItem> _phrasePronunciation = [
PronunciationItem(
id: 'phrase_001',
text: 'Nice to meet you',
phonetic: '/naɪs tuː miːt juː/',
audioUrl: 'assets/audio/nice_to_meet.mp3',
type: PronunciationType.phrase,
difficulty: DifficultyLevel.beginner,
category: '问候语',
tips: [
'连读Nice to → /naɪstə/',
'友好的语调',
'重音在Nice和meet上'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'phrase_002',
text: 'Thank you very much',
phonetic: '/θæŋk juː ˈveri mʌtʃ/',
audioUrl: 'assets/audio/thank_you_much.mp3',
type: PronunciationType.phrase,
difficulty: DifficultyLevel.beginner,
category: '感谢语',
tips: [
'注意th音的发音',
'very much重音分布',
'真诚的语调'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'phrase_003',
text: 'Excuse me',
phonetic: '/ɪkˈskjuːz miː/',
audioUrl: 'assets/audio/excuse_me.mp3',
type: PronunciationType.phrase,
difficulty: DifficultyLevel.beginner,
category: '礼貌用语',
tips: [
'重音在excuse的第二个音节',
'礼貌的上升语调',
'清晰的/z/音'
],
createdAt: DateTime.now(),
),
];
static final List<PronunciationItem> _phonemePronunciation = [
PronunciationItem(
id: 'phoneme_001',
text: '/θ/ - think, three, thank',
phonetic: '/θ/',
audioUrl: 'assets/audio/th_sound.mp3',
type: PronunciationType.phoneme,
difficulty: DifficultyLevel.intermediate,
category: '辅音',
tips: [
'舌尖轻触上齿',
'气流从舌齿间通过',
'不要发成/s/或/f/'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'phoneme_002',
text: '/r/ - red, right, very',
phonetic: '/r/',
audioUrl: 'assets/audio/r_sound.mp3',
type: PronunciationType.phoneme,
difficulty: DifficultyLevel.advanced,
category: '辅音',
tips: [
'舌尖向上卷起',
'不要触碰口腔任何部位',
'声带振动'
],
createdAt: DateTime.now(),
),
PronunciationItem(
id: 'phoneme_003',
text: '/æ/ - cat, hat, bad',
phonetic: '/æ/',
audioUrl: 'assets/audio/ae_sound.mp3',
type: PronunciationType.phoneme,
difficulty: DifficultyLevel.intermediate,
category: '元音',
tips: [
'嘴巴张得比/e/大',
'舌位较低',
'短元音,发音清脆'
],
createdAt: DateTime.now(),
),
];
/// 获取所有发音练习项目
static List<PronunciationItem> getAllItems() {
return [
..._wordPronunciation,
..._sentencePronunciation,
..._phrasePronunciation,
..._phonemePronunciation,
];
}
/// 根据类型获取发音练习项目
static List<PronunciationItem> getItemsByType(PronunciationType type) {
switch (type) {
case PronunciationType.word:
return _wordPronunciation;
case PronunciationType.sentence:
return _sentencePronunciation;
case PronunciationType.phrase:
return _phrasePronunciation;
case PronunciationType.phoneme:
return _phonemePronunciation;
}
}
/// 根据难度获取发音练习项目
static List<PronunciationItem> getItemsByDifficulty(DifficultyLevel difficulty) {
return getAllItems().where((item) => item.difficulty == difficulty).toList();
}
/// 根据分类获取发音练习项目
static List<PronunciationItem> getItemsByCategory(String category) {
return getAllItems().where((item) => item.category == category).toList();
}
/// 根据ID获取发音练习项目
static PronunciationItem? getItemById(String id) {
try {
return getAllItems().firstWhere((item) => item.id == id);
} catch (e) {
return null;
}
}
/// 获取所有分类
static List<String> getAllCategories() {
return getAllItems().map((item) => item.category).toSet().toList();
}
}

View File

@@ -0,0 +1,304 @@
import '../models/conversation_scenario.dart';
/// 对话场景静态数据
class ScenarioData {
static final List<ConversationScenario> _scenarios = [
ConversationScenario(
id: 'restaurant_ordering',
title: 'Ordering Food at Restaurant',
subtitle: '餐厅点餐对话',
description: '学习在餐厅点餐的常用英语表达,包括询问菜单、下订单、特殊要求等。',
duration: '10分钟',
level: 'B1',
type: ScenarioType.restaurant,
objectives: [
'学会阅读英文菜单',
'掌握点餐的基本表达',
'学会提出特殊要求',
'了解餐厅服务流程',
],
keyPhrases: [
'I\'d like to order...',
'What do you recommend?',
'Could I have the menu, please?',
'I\'m allergic to...',
'Could you make it less spicy?',
'Check, please.',
],
steps: [
ScenarioStep(
stepNumber: 1,
title: '入座问候',
description: '服务员引导您入座并提供菜单',
role: 'npc',
content: 'Good evening! Welcome to our restaurant. How many people are in your party?',
options: [
'Just one, please.',
'Table for two, please.',
'We have a reservation under Smith.',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 2,
title: '查看菜单',
description: '服务员询问您是否需要时间看菜单',
role: 'npc',
content: 'Here\'s your table. Would you like to see the menu, or do you need a few minutes?',
options: [
'Could I have the menu, please?',
'I need a few minutes to decide.',
'What do you recommend?',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 3,
title: '点餐',
description: '服务员准备为您点餐',
role: 'npc',
content: 'Are you ready to order, or would you like to hear about our specials?',
options: [
'I\'d like to hear about the specials.',
'I\'m ready to order.',
'Could you give me a few more minutes?',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 4,
title: '下订单',
description: '告诉服务员您想要什么',
role: 'user',
content: 'What would you like to order?',
options: [
'I\'d like the grilled salmon, please.',
'Could I have the pasta with marinara sauce?',
'I\'ll have the chicken Caesar salad.',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 5,
title: '特殊要求',
description: '提出任何特殊的饮食要求',
role: 'npc',
content: 'How would you like that cooked? Any special requests?',
options: [
'Medium rare, please.',
'Could you make it less spicy?',
'I\'m allergic to nuts, please make sure there are none.',
],
correctOption: null,
),
],
createdAt: DateTime.now(),
),
ConversationScenario(
id: 'job_interview',
title: 'Job Interview Practice',
subtitle: '工作面试练习',
description: '模拟真实的英语工作面试场景,练习自我介绍、回答常见问题和提问技巧。',
duration: '15分钟',
level: 'B2',
type: ScenarioType.interview,
objectives: [
'掌握自我介绍技巧',
'学会回答常见面试问题',
'练习询问公司信息',
'提高面试自信心',
],
keyPhrases: [
'Tell me about yourself.',
'What are your strengths?',
'Why do you want this job?',
'Where do you see yourself in 5 years?',
'Do you have any questions for us?',
'Thank you for your time.',
],
steps: [
ScenarioStep(
stepNumber: 1,
title: '面试开始',
description: '面试官欢迎您并开始面试',
role: 'npc',
content: 'Good morning! Please have a seat. Thank you for coming in today.',
options: [
'Good morning! Thank you for having me.',
'Hello! I\'m excited to be here.',
'Thank you for the opportunity.',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 2,
title: '自我介绍',
description: '面试官要求您进行自我介绍',
role: 'npc',
content: 'Let\'s start with you telling me a little bit about yourself.',
options: [
'I have 5 years of experience in marketing...',
'I\'m a recent graduate with a degree in...',
'I\'m currently working as a... and looking for new challenges.',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 3,
title: '优势询问',
description: '面试官询问您的优势',
role: 'npc',
content: 'What would you say are your greatest strengths?',
options: [
'I\'m very detail-oriented and organized.',
'I work well under pressure and meet deadlines.',
'I\'m a strong team player with good communication skills.',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 4,
title: '职业规划',
description: '面试官询问您的职业规划',
role: 'npc',
content: 'Where do you see yourself in five years?',
options: [
'I see myself in a leadership role, managing a team.',
'I want to become an expert in my field.',
'I hope to have grown professionally and taken on more responsibilities.',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 5,
title: '提问环节',
description: '面试官询问您是否有问题',
role: 'npc',
content: 'Do you have any questions about the company or the position?',
options: [
'What does a typical day look like in this role?',
'What are the opportunities for professional development?',
'What do you enjoy most about working here?',
],
correctOption: null,
),
],
createdAt: DateTime.now(),
),
ConversationScenario(
id: 'business_meeting',
title: 'Business Meeting Discussion',
subtitle: '商务会议讨论',
description: '参与英语商务会议,学习表达观点、提出建议和进行专业讨论。',
duration: '20分钟',
level: 'C1',
type: ScenarioType.business,
objectives: [
'学会在会议中表达观点',
'掌握商务讨论技巧',
'练习提出建议和反对意见',
'提高商务英语水平',
],
keyPhrases: [
'I\'d like to propose...',
'From my perspective...',
'I agree with your point, however...',
'Could we consider...',
'Let\'s move on to the next item.',
'To summarize...',
],
steps: [
ScenarioStep(
stepNumber: 1,
title: '会议开始',
description: '会议主持人开始会议并介绍议程',
role: 'npc',
content: 'Good morning, everyone. Let\'s begin today\'s meeting. Our main agenda is to discuss the new marketing strategy.',
options: [
'Good morning! I\'m looking forward to the discussion.',
'Thank you for organizing this meeting.',
'I have some ideas I\'d like to share.',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 2,
title: '观点表达',
description: '主持人邀请您分享观点',
role: 'npc',
content: 'What are your thoughts on our current marketing approach?',
options: [
'I think we should focus more on digital marketing.',
'From my perspective, we need to target younger demographics.',
'I believe our current strategy is effective, but we could improve...',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 3,
title: '建议提出',
description: '有人提出了一个建议,您需要回应',
role: 'npc',
content: 'I propose we increase our social media budget by 30%. What do you think?',
options: [
'That\'s an interesting proposal. Could we see some data first?',
'I agree, but we should also consider the ROI.',
'I have some concerns about that approach.',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 4,
title: '讨论深入',
description: '深入讨论具体实施方案',
role: 'npc',
content: 'How do you suggest we implement this new strategy?',
options: [
'We could start with a pilot program.',
'I recommend we form a dedicated team.',
'Let\'s set clear milestones and deadlines.',
],
correctOption: null,
),
ScenarioStep(
stepNumber: 5,
title: '会议总结',
description: '总结会议要点和下一步行动',
role: 'npc',
content: 'Let\'s summarize what we\'ve discussed and assign action items.',
options: [
'I can take responsibility for the market research.',
'Should we schedule a follow-up meeting?',
'I\'ll prepare a detailed proposal by next week.',
],
correctOption: null,
),
],
createdAt: DateTime.now(),
),
];
/// 获取所有场景
static List<ConversationScenario> getAllScenarios() {
return List.from(_scenarios);
}
/// 根据ID获取场景
static ConversationScenario? getScenarioById(String id) {
try {
return _scenarios.firstWhere((scenario) => scenario.id == id);
} catch (e) {
return null;
}
}
/// 根据类型获取场景
static List<ConversationScenario> getScenariosByType(ScenarioType type) {
return _scenarios.where((scenario) => scenario.type == type).toList();
}
/// 根据难度级别获取场景
static List<ConversationScenario> getScenariosByLevel(String level) {
return _scenarios.where((scenario) => scenario.level == level).toList();
}
}

View File

@@ -0,0 +1,338 @@
import '../models/speaking_scenario.dart';
/// 口语练习静态数据
class SpeakingStaticData {
static final List<SpeakingTask> _tasks = [
// 日常对话场景
SpeakingTask(
id: 'daily_001',
title: '自我介绍',
description: '学习如何进行基本的自我介绍',
scenario: SpeakingScenario.dailyConversation,
difficulty: SpeakingDifficulty.beginner,
objectives: [
'能够清晰地介绍自己的姓名、年龄和职业',
'掌握基本的问候语和礼貌用语',
'学会询问他人的基本信息',
],
keyPhrases: [
'My name is...',
'I am from...',
'I work as...',
'Nice to meet you',
'How about you?',
],
backgroundInfo: '在日常生活中,自我介绍是最基本的社交技能。无论是在工作场合还是社交聚会,都需要用到这项技能。',
estimatedDuration: 10,
isRecommended: true,
createdAt: DateTime.now().subtract(const Duration(days: 30)),
updatedAt: DateTime.now().subtract(const Duration(days: 30)),
),
SpeakingTask(
id: 'daily_002',
title: '询问方向',
description: '学习如何询问和指示方向',
scenario: SpeakingScenario.dailyConversation,
difficulty: SpeakingDifficulty.elementary,
objectives: [
'能够礼貌地询问方向',
'理解和给出简单的方向指示',
'掌握常用的地点和方向词汇',
],
keyPhrases: [
'Excuse me, where is...?',
'How can I get to...?',
'Go straight',
'Turn left/right',
'It\'s on your left/right',
],
estimatedDuration: 15,
createdAt: DateTime.now().subtract(const Duration(days: 25)),
updatedAt: DateTime.now().subtract(const Duration(days: 25)),
),
// 商务会议场景
SpeakingTask(
id: 'business_001',
title: '会议开场',
description: '学习如何主持和参与会议开场',
scenario: SpeakingScenario.businessMeeting,
difficulty: SpeakingDifficulty.intermediate,
objectives: [
'能够正式地开始会议',
'介绍会议议程和参与者',
'掌握商务会议的基本礼仪',
],
keyPhrases: [
'Let\'s get started',
'Today\'s agenda includes...',
'I\'d like to introduce...',
'The purpose of this meeting is...',
'Any questions before we begin?',
],
backgroundInfo: '商务会议是职场中重要的沟通方式,掌握会议开场技巧能够提升专业形象。',
estimatedDuration: 20,
isRecommended: true,
createdAt: DateTime.now().subtract(const Duration(days: 20)),
updatedAt: DateTime.now().subtract(const Duration(days: 20)),
),
SpeakingTask(
id: 'business_002',
title: '产品介绍',
description: '学习如何向客户介绍产品',
scenario: SpeakingScenario.businessMeeting,
difficulty: SpeakingDifficulty.upperIntermediate,
objectives: [
'能够清晰地描述产品特点',
'强调产品的优势和价值',
'回答客户的疑问',
],
keyPhrases: [
'Our product features...',
'The main benefit is...',
'This will help you...',
'Compared to competitors...',
'Would you like to know more about...?',
],
estimatedDuration: 25,
createdAt: DateTime.now().subtract(const Duration(days: 18)),
updatedAt: DateTime.now().subtract(const Duration(days: 18)),
),
// 求职面试场景
SpeakingTask(
id: 'interview_001',
title: '面试自我介绍',
description: '学习面试中的专业自我介绍',
scenario: SpeakingScenario.jobInterview,
difficulty: SpeakingDifficulty.intermediate,
objectives: [
'能够简洁有力地介绍自己',
'突出相关工作经验和技能',
'展现对职位的兴趣和热情',
],
keyPhrases: [
'I have X years of experience in...',
'My background includes...',
'I\'m particularly skilled at...',
'I\'m excited about this opportunity because...',
'I believe I would be a good fit because...',
],
backgroundInfo: '面试自我介绍是求职过程中的关键环节,需要在短时间内给面试官留下深刻印象。',
estimatedDuration: 15,
isRecommended: true,
createdAt: DateTime.now().subtract(const Duration(days: 15)),
updatedAt: DateTime.now().subtract(const Duration(days: 15)),
),
// 购物场景
SpeakingTask(
id: 'shopping_001',
title: '服装购买',
description: '学习在服装店购物的对话',
scenario: SpeakingScenario.shopping,
difficulty: SpeakingDifficulty.elementary,
objectives: [
'能够询问商品信息',
'表达对尺寸和颜色的需求',
'进行价格谈判和付款',
],
keyPhrases: [
'Do you have this in size...?',
'Can I try this on?',
'How much does this cost?',
'Do you accept credit cards?',
'I\'ll take it',
],
estimatedDuration: 12,
createdAt: DateTime.now().subtract(const Duration(days: 12)),
updatedAt: DateTime.now().subtract(const Duration(days: 12)),
),
// 餐厅场景
SpeakingTask(
id: 'restaurant_001',
title: '餐厅点餐',
description: '学习在餐厅点餐的完整流程',
scenario: SpeakingScenario.restaurant,
difficulty: SpeakingDifficulty.elementary,
objectives: [
'能够预订餐桌',
'阅读菜单并点餐',
'处理特殊饮食要求',
],
keyPhrases: [
'I\'d like to make a reservation',
'Can I see the menu?',
'I\'ll have the...',
'I\'m allergic to...',
'Could I get the check, please?',
],
estimatedDuration: 18,
createdAt: DateTime.now().subtract(const Duration(days: 10)),
updatedAt: DateTime.now().subtract(const Duration(days: 10)),
),
// 旅行场景
SpeakingTask(
id: 'travel_001',
title: '机场办理登机',
description: '学习在机场办理登机手续',
scenario: SpeakingScenario.travel,
difficulty: SpeakingDifficulty.intermediate,
objectives: [
'能够办理登机手续',
'处理行李托运',
'询问航班信息',
],
keyPhrases: [
'I\'d like to check in',
'Here\'s my passport',
'I have one bag to check',
'What gate is my flight?',
'What time is boarding?',
],
backgroundInfo: '机场是国际旅行的重要场所,掌握相关英语对话能够让旅行更加顺利。',
estimatedDuration: 20,
createdAt: DateTime.now().subtract(const Duration(days: 8)),
updatedAt: DateTime.now().subtract(const Duration(days: 8)),
),
// 学术讨论场景
SpeakingTask(
id: 'academic_001',
title: '课堂讨论',
description: '学习参与学术课堂讨论',
scenario: SpeakingScenario.academic,
difficulty: SpeakingDifficulty.advanced,
objectives: [
'能够表达学术观点',
'进行逻辑性论证',
'礼貌地反驳不同观点',
],
keyPhrases: [
'In my opinion...',
'The evidence suggests...',
'I disagree because...',
'That\'s an interesting point, however...',
'Could you elaborate on...?',
],
estimatedDuration: 30,
createdAt: DateTime.now().subtract(const Duration(days: 5)),
updatedAt: DateTime.now().subtract(const Duration(days: 5)),
),
// 社交聚会场景
SpeakingTask(
id: 'social_001',
title: '聚会闲聊',
description: '学习在社交聚会中的轻松对话',
scenario: SpeakingScenario.socializing,
difficulty: SpeakingDifficulty.intermediate,
objectives: [
'能够进行轻松的闲聊',
'分享个人兴趣和经历',
'保持对话的自然流畅',
],
keyPhrases: [
'How do you know the host?',
'What do you do for fun?',
'Have you been here before?',
'That sounds interesting!',
'I should probably get going',
],
estimatedDuration: 15,
createdAt: DateTime.now().subtract(const Duration(days: 3)),
updatedAt: DateTime.now().subtract(const Duration(days: 3)),
),
// 演讲展示场景
SpeakingTask(
id: 'presentation_001',
title: '产品演示',
description: '学习进行产品演示和展示',
scenario: SpeakingScenario.presentation,
difficulty: SpeakingDifficulty.advanced,
objectives: [
'能够结构化地组织演示内容',
'使用视觉辅助工具',
'处理观众的问题和反馈',
],
keyPhrases: [
'Today I\'ll be presenting...',
'As you can see in this slide...',
'Let me demonstrate...',
'Are there any questions?',
'Thank you for your attention',
],
backgroundInfo: '产品演示是商务环境中的重要技能,需要结合专业知识和演讲技巧。',
estimatedDuration: 35,
isRecommended: true,
createdAt: DateTime.now().subtract(const Duration(days: 1)),
updatedAt: DateTime.now().subtract(const Duration(days: 1)),
),
];
/// 获取所有任务
static List<SpeakingTask> getAllTasks() {
return List.from(_tasks);
}
/// 根据场景获取任务
static List<SpeakingTask> getTasksByScenario(SpeakingScenario scenario) {
return _tasks.where((task) => task.scenario == scenario).toList();
}
/// 根据难度获取任务
static List<SpeakingTask> getTasksByDifficulty(SpeakingDifficulty difficulty) {
return _tasks.where((task) => task.difficulty == difficulty).toList();
}
/// 获取推荐任务
static List<SpeakingTask> getRecommendedTasks() {
return _tasks.where((task) => task.isRecommended).toList();
}
/// 获取最近任务
static List<SpeakingTask> getRecentTasks() {
final sortedTasks = List<SpeakingTask>.from(_tasks);
sortedTasks.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return sortedTasks.take(5).toList();
}
/// 获取热门任务(按完成次数排序)
static List<SpeakingTask> getPopularTasks() {
final sortedTasks = List<SpeakingTask>.from(_tasks);
sortedTasks.sort((a, b) => b.completionCount.compareTo(a.completionCount));
return sortedTasks.take(5).toList();
}
/// 根据ID获取任务
static SpeakingTask? getTaskById(String id) {
try {
return _tasks.firstWhere((task) => task.id == id);
} catch (e) {
return null;
}
}
/// 获取场景统计
static Map<SpeakingScenario, int> getScenarioStats() {
final stats = <SpeakingScenario, int>{};
for (final scenario in SpeakingScenario.values) {
stats[scenario] = _tasks.where((task) => task.scenario == scenario).length;
}
return stats;
}
/// 获取难度统计
static Map<SpeakingDifficulty, int> getDifficultyStats() {
final stats = <SpeakingDifficulty, int>{};
for (final difficulty in SpeakingDifficulty.values) {
stats[difficulty] = _tasks.where((task) => task.difficulty == difficulty).length;
}
return stats;
}
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
/// AI导师类型枚举
enum TutorType {
business,
daily,
travel,
academic,
}
/// AI导师扩展方法
extension TutorTypeExtension on TutorType {
String get displayName {
switch (this) {
case TutorType.business:
return '商务导师';
case TutorType.daily:
return '日常导师';
case TutorType.travel:
return '旅行导师';
case TutorType.academic:
return '学术导师';
}
}
String get description {
switch (this) {
case TutorType.business:
return '专业商务场景';
case TutorType.daily:
return '生活场景对话';
case TutorType.travel:
return '旅游场景专训';
case TutorType.academic:
return '学术讨论演讲';
}
}
IconData get icon {
switch (this) {
case TutorType.business:
return Icons.business_center;
case TutorType.daily:
return Icons.chat;
case TutorType.travel:
return Icons.flight;
case TutorType.academic:
return Icons.school;
}
}
Color get color {
switch (this) {
case TutorType.business:
return Colors.blue;
case TutorType.daily:
return Colors.green;
case TutorType.travel:
return Colors.orange;
case TutorType.academic:
return Colors.purple;
}
}
}
/// AI导师模型
class AITutor {
final String id;
final TutorType type;
final String name;
final String avatar;
final String introduction;
final List<String> specialties;
final List<String> sampleQuestions;
final String personality;
final DateTime createdAt;
final DateTime updatedAt;
const AITutor({
required this.id,
required this.type,
required this.name,
required this.avatar,
required this.introduction,
required this.specialties,
required this.sampleQuestions,
required this.personality,
required this.createdAt,
required this.updatedAt,
});
factory AITutor.fromJson(Map<String, dynamic> json) {
return AITutor(
id: json['id'] as String,
type: TutorType.values.firstWhere(
(e) => e.name == json['type'],
orElse: () => TutorType.daily,
),
name: json['name'] as String,
avatar: json['avatar'] as String,
introduction: json['introduction'] as String,
specialties: List<String>.from(json['specialties'] as List),
sampleQuestions: List<String>.from(json['sampleQuestions'] as List),
personality: json['personality'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type.name,
'name': name,
'avatar': avatar,
'introduction': introduction,
'specialties': specialties,
'sampleQuestions': sampleQuestions,
'personality': personality,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
AITutor copyWith({
String? id,
TutorType? type,
String? name,
String? avatar,
String? introduction,
List<String>? specialties,
List<String>? sampleQuestions,
String? personality,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return AITutor(
id: id ?? this.id,
type: type ?? this.type,
name: name ?? this.name,
avatar: avatar ?? this.avatar,
introduction: introduction ?? this.introduction,
specialties: specialties ?? this.specialties,
sampleQuestions: sampleQuestions ?? this.sampleQuestions,
personality: personality ?? this.personality,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}

View File

@@ -0,0 +1,155 @@
enum MessageType {
user,
ai,
system;
}
enum ConversationStatus {
active,
paused,
completed,
cancelled;
String get displayName {
switch (this) {
case ConversationStatus.active:
return '进行中';
case ConversationStatus.paused:
return '已暂停';
case ConversationStatus.completed:
return '已完成';
case ConversationStatus.cancelled:
return '已取消';
}
}
}
class ConversationMessage {
final String id;
final String content;
final MessageType type;
final DateTime timestamp;
final String? audioUrl;
final double? confidence; // 语音识别置信度
final Map<String, dynamic>? metadata;
const ConversationMessage({
required this.id,
required this.content,
required this.type,
required this.timestamp,
this.audioUrl,
this.confidence,
this.metadata,
});
factory ConversationMessage.fromJson(Map<String, dynamic> json) {
return ConversationMessage(
id: json['id'] as String,
content: json['content'] as String,
type: MessageType.values.firstWhere(
(e) => e.name == json['type'],
orElse: () => MessageType.user,
),
timestamp: DateTime.parse(json['timestamp'] as String),
audioUrl: json['audioUrl'] as String?,
confidence: json['confidence'] as double?,
metadata: json['metadata'] as Map<String, dynamic>?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'content': content,
'type': type.name,
'timestamp': timestamp.toIso8601String(),
'audioUrl': audioUrl,
'confidence': confidence,
'metadata': metadata,
};
}
}
class Conversation {
final String id;
final String taskId;
final String userId;
final List<ConversationMessage> messages;
final ConversationStatus status;
final DateTime startTime;
final DateTime? endTime;
final int totalDuration; // 总时长(秒)
final Map<String, dynamic>? settings;
const Conversation({
required this.id,
required this.taskId,
required this.userId,
required this.messages,
required this.status,
required this.startTime,
this.endTime,
required this.totalDuration,
this.settings,
});
factory Conversation.fromJson(Map<String, dynamic> json) {
return Conversation(
id: json['id'] as String,
taskId: json['taskId'] as String,
userId: json['userId'] as String,
messages: (json['messages'] as List<dynamic>)
.map((e) => ConversationMessage.fromJson(e as Map<String, dynamic>))
.toList(),
status: ConversationStatus.values.firstWhere(
(e) => e.name == json['status'],
orElse: () => ConversationStatus.active,
),
startTime: DateTime.parse(json['startTime'] as String),
endTime: json['endTime'] != null
? DateTime.parse(json['endTime'] as String)
: null,
totalDuration: json['totalDuration'] as int,
settings: json['settings'] as Map<String, dynamic>?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'taskId': taskId,
'userId': userId,
'messages': messages.map((e) => e.toJson()).toList(),
'status': status.name,
'startTime': startTime.toIso8601String(),
'endTime': endTime?.toIso8601String(),
'totalDuration': totalDuration,
'settings': settings,
};
}
Conversation copyWith({
String? id,
String? taskId,
String? userId,
List<ConversationMessage>? messages,
ConversationStatus? status,
DateTime? startTime,
DateTime? endTime,
int? totalDuration,
Map<String, dynamic>? settings,
}) {
return Conversation(
id: id ?? this.id,
taskId: taskId ?? this.taskId,
userId: userId ?? this.userId,
messages: messages ?? this.messages,
status: status ?? this.status,
startTime: startTime ?? this.startTime,
endTime: endTime ?? this.endTime,
totalDuration: totalDuration ?? this.totalDuration,
settings: settings ?? this.settings,
);
}
}

View File

@@ -0,0 +1,193 @@
/// 对话场景数据模型
class ConversationScenario {
final String id;
final String title;
final String subtitle;
final String description;
final String duration;
final String level;
final ScenarioType type;
final List<String> objectives;
final List<String> keyPhrases;
final List<ScenarioStep> steps;
final DateTime createdAt;
ConversationScenario({
required this.id,
required this.title,
required this.subtitle,
required this.description,
required this.duration,
required this.level,
required this.type,
required this.objectives,
required this.keyPhrases,
required this.steps,
required this.createdAt,
});
factory ConversationScenario.fromJson(Map<String, dynamic> json) {
return ConversationScenario(
id: json['id'],
title: json['title'],
subtitle: json['subtitle'],
description: json['description'],
duration: json['duration'],
level: json['level'],
type: ScenarioType.values.firstWhere(
(e) => e.toString().split('.').last == json['type'],
),
objectives: List<String>.from(json['objectives']),
keyPhrases: List<String>.from(json['keyPhrases']),
steps: (json['steps'] as List)
.map((step) => ScenarioStep.fromJson(step))
.toList(),
createdAt: DateTime.parse(json['createdAt']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'subtitle': subtitle,
'description': description,
'duration': duration,
'level': level,
'type': type.toString().split('.').last,
'objectives': objectives,
'keyPhrases': keyPhrases,
'steps': steps.map((step) => step.toJson()).toList(),
'createdAt': createdAt.toIso8601String(),
};
}
ConversationScenario copyWith({
String? id,
String? title,
String? subtitle,
String? description,
String? duration,
String? level,
ScenarioType? type,
List<String>? objectives,
List<String>? keyPhrases,
List<ScenarioStep>? steps,
DateTime? createdAt,
}) {
return ConversationScenario(
id: id ?? this.id,
title: title ?? this.title,
subtitle: subtitle ?? this.subtitle,
description: description ?? this.description,
duration: duration ?? this.duration,
level: level ?? this.level,
type: type ?? this.type,
objectives: objectives ?? this.objectives,
keyPhrases: keyPhrases ?? this.keyPhrases,
steps: steps ?? this.steps,
createdAt: createdAt ?? this.createdAt,
);
}
}
/// 场景类型枚举
enum ScenarioType {
restaurant,
interview,
business,
travel,
shopping,
medical,
education,
social,
}
extension ScenarioTypeExtension on ScenarioType {
String get displayName {
switch (this) {
case ScenarioType.restaurant:
return '餐厅用餐';
case ScenarioType.interview:
return '工作面试';
case ScenarioType.business:
return '商务会议';
case ScenarioType.travel:
return '旅行出行';
case ScenarioType.shopping:
return '购物消费';
case ScenarioType.medical:
return '医疗健康';
case ScenarioType.education:
return '教育学习';
case ScenarioType.social:
return '社交聚会';
}
}
String get icon {
switch (this) {
case ScenarioType.restaurant:
return '🍽️';
case ScenarioType.interview:
return '💼';
case ScenarioType.business:
return '🏢';
case ScenarioType.travel:
return '✈️';
case ScenarioType.shopping:
return '🛍️';
case ScenarioType.medical:
return '🏥';
case ScenarioType.education:
return '📚';
case ScenarioType.social:
return '🎉';
}
}
}
/// 场景步骤
class ScenarioStep {
final int stepNumber;
final String title;
final String description;
final String role; // 'user' or 'npc'
final String content;
final List<String> options;
final String? correctOption;
ScenarioStep({
required this.stepNumber,
required this.title,
required this.description,
required this.role,
required this.content,
required this.options,
this.correctOption,
});
factory ScenarioStep.fromJson(Map<String, dynamic> json) {
return ScenarioStep(
stepNumber: json['stepNumber'],
title: json['title'],
description: json['description'],
role: json['role'],
content: json['content'],
options: List<String>.from(json['options']),
correctOption: json['correctOption'],
);
}
Map<String, dynamic> toJson() {
return {
'stepNumber': stepNumber,
'title': title,
'description': description,
'role': role,
'content': content,
'options': options,
'correctOption': correctOption,
};
}
}

View File

@@ -0,0 +1,176 @@
enum PronunciationCriteria {
accuracy,
fluency,
completeness,
prosody;
String get displayName {
switch (this) {
case PronunciationCriteria.accuracy:
return '准确性';
case PronunciationCriteria.fluency:
return '流利度';
case PronunciationCriteria.completeness:
return '完整性';
case PronunciationCriteria.prosody:
return '韵律';
}
}
String get description {
switch (this) {
case PronunciationCriteria.accuracy:
return '发音的准确程度';
case PronunciationCriteria.fluency:
return '语音的流畅程度';
case PronunciationCriteria.completeness:
return '内容的完整程度';
case PronunciationCriteria.prosody:
return '语调和节奏的自然程度';
}
}
}
class WordPronunciation {
final String word;
final double accuracyScore; // 0-100
final String? errorType;
final List<String> phonemes;
final List<double> phonemeScores;
final int startTime; // 毫秒
final int endTime; // 毫秒
const WordPronunciation({
required this.word,
required this.accuracyScore,
this.errorType,
required this.phonemes,
required this.phonemeScores,
required this.startTime,
required this.endTime,
});
factory WordPronunciation.fromJson(Map<String, dynamic> json) {
return WordPronunciation(
word: json['word'] as String,
accuracyScore: (json['accuracyScore'] as num).toDouble(),
errorType: json['errorType'] as String?,
phonemes: List<String>.from(json['phonemes'] ?? []),
phonemeScores: List<double>.from(
(json['phonemeScores'] as List<dynamic>? ?? [])
.map((e) => (e as num).toDouble()),
),
startTime: json['startTime'] as int,
endTime: json['endTime'] as int,
);
}
Map<String, dynamic> toJson() {
return {
'word': word,
'accuracyScore': accuracyScore,
'errorType': errorType,
'phonemes': phonemes,
'phonemeScores': phonemeScores,
'startTime': startTime,
'endTime': endTime,
};
}
}
class PronunciationAssessment {
final String id;
final String conversationId;
final String messageId;
final String originalText;
final String recognizedText;
final Map<PronunciationCriteria, double> scores; // 0-100
final double overallScore; // 0-100
final List<WordPronunciation> wordDetails;
final List<String> suggestions;
final DateTime assessedAt;
final Map<String, dynamic>? metadata;
const PronunciationAssessment({
required this.id,
required this.conversationId,
required this.messageId,
required this.originalText,
required this.recognizedText,
required this.scores,
required this.overallScore,
required this.wordDetails,
required this.suggestions,
required this.assessedAt,
this.metadata,
});
factory PronunciationAssessment.fromJson(Map<String, dynamic> json) {
final scoresMap = <PronunciationCriteria, double>{};
final scoresJson = json['scores'] as Map<String, dynamic>? ?? {};
for (final criteria in PronunciationCriteria.values) {
scoresMap[criteria] = (scoresJson[criteria.name] as num?)?.toDouble() ?? 0.0;
}
return PronunciationAssessment(
id: json['id'] as String,
conversationId: json['conversationId'] as String,
messageId: json['messageId'] as String,
originalText: json['originalText'] as String,
recognizedText: json['recognizedText'] as String,
scores: scoresMap,
overallScore: (json['overallScore'] as num).toDouble(),
wordDetails: (json['wordDetails'] as List<dynamic>? ?? [])
.map((e) => WordPronunciation.fromJson(e as Map<String, dynamic>))
.toList(),
suggestions: List<String>.from(json['suggestions'] ?? []),
assessedAt: DateTime.parse(json['assessedAt'] as String),
metadata: json['metadata'] as Map<String, dynamic>?,
);
}
Map<String, dynamic> toJson() {
final scoresJson = <String, double>{};
for (final entry in scores.entries) {
scoresJson[entry.key.name] = entry.value;
}
return {
'id': id,
'conversationId': conversationId,
'messageId': messageId,
'originalText': originalText,
'recognizedText': recognizedText,
'scores': scoresJson,
'overallScore': overallScore,
'wordDetails': wordDetails.map((e) => e.toJson()).toList(),
'suggestions': suggestions,
'assessedAt': assessedAt.toIso8601String(),
'metadata': metadata,
};
}
String get accuracyLevel {
if (overallScore >= 90) return '优秀';
if (overallScore >= 80) return '良好';
if (overallScore >= 70) return '中等';
if (overallScore >= 60) return '及格';
return '需要改进';
}
List<String> get mainIssues {
final issues = <String>[];
if (scores[PronunciationCriteria.accuracy]! < 70) {
issues.add('发音准确性需要提高');
}
if (scores[PronunciationCriteria.fluency]! < 70) {
issues.add('语音流利度有待改善');
}
if (scores[PronunciationCriteria.prosody]! < 70) {
issues.add('语调和节奏需要调整');
}
return issues;
}
}

View File

@@ -0,0 +1,201 @@
/// 发音练习项目数据模型
class PronunciationItem {
final String id;
final String text;
final String phonetic;
final String audioUrl;
final PronunciationType type;
final DifficultyLevel difficulty;
final String category;
final List<String> tips;
final DateTime createdAt;
PronunciationItem({
required this.id,
required this.text,
required this.phonetic,
required this.audioUrl,
required this.type,
required this.difficulty,
required this.category,
required this.tips,
required this.createdAt,
});
factory PronunciationItem.fromJson(Map<String, dynamic> json) {
return PronunciationItem(
id: json['id'],
text: json['text'],
phonetic: json['phonetic'],
audioUrl: json['audioUrl'],
type: PronunciationType.values.firstWhere(
(e) => e.toString().split('.').last == json['type'],
),
difficulty: DifficultyLevel.values.firstWhere(
(e) => e.toString().split('.').last == json['difficulty'],
),
category: json['category'],
tips: List<String>.from(json['tips']),
createdAt: DateTime.parse(json['createdAt']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'text': text,
'phonetic': phonetic,
'audioUrl': audioUrl,
'type': type.toString().split('.').last,
'difficulty': difficulty.toString().split('.').last,
'category': category,
'tips': tips,
'createdAt': createdAt.toIso8601String(),
};
}
PronunciationItem copyWith({
String? id,
String? text,
String? phonetic,
String? audioUrl,
PronunciationType? type,
DifficultyLevel? difficulty,
String? category,
List<String>? tips,
DateTime? createdAt,
}) {
return PronunciationItem(
id: id ?? this.id,
text: text ?? this.text,
phonetic: phonetic ?? this.phonetic,
audioUrl: audioUrl ?? this.audioUrl,
type: type ?? this.type,
difficulty: difficulty ?? this.difficulty,
category: category ?? this.category,
tips: tips ?? this.tips,
createdAt: createdAt ?? this.createdAt,
);
}
}
/// 发音练习类型
enum PronunciationType {
word,
sentence,
phrase,
phoneme,
}
extension PronunciationTypeExtension on PronunciationType {
String get displayName {
switch (this) {
case PronunciationType.word:
return '单词发音';
case PronunciationType.sentence:
return '句子朗读';
case PronunciationType.phrase:
return '短语练习';
case PronunciationType.phoneme:
return '音素练习';
}
}
String get description {
switch (this) {
case PronunciationType.word:
return '练习单个单词的准确发音';
case PronunciationType.sentence:
return '练习完整句子的语调和节奏';
case PronunciationType.phrase:
return '练习常用短语的连读';
case PronunciationType.phoneme:
return '练习基础音素的发音';
}
}
String get icon {
switch (this) {
case PronunciationType.word:
return '🔤';
case PronunciationType.sentence:
return '📝';
case PronunciationType.phrase:
return '💬';
case PronunciationType.phoneme:
return '🔊';
}
}
}
/// 难度级别
enum DifficultyLevel {
beginner,
intermediate,
advanced,
}
extension DifficultyLevelExtension on DifficultyLevel {
String get displayName {
switch (this) {
case DifficultyLevel.beginner:
return '初级';
case DifficultyLevel.intermediate:
return '中级';
case DifficultyLevel.advanced:
return '高级';
}
}
String get code {
switch (this) {
case DifficultyLevel.beginner:
return 'A1-A2';
case DifficultyLevel.intermediate:
return 'B1-B2';
case DifficultyLevel.advanced:
return 'C1-C2';
}
}
}
/// 发音练习记录
class PronunciationRecord {
final String id;
final String itemId;
final double score;
final String feedback;
final DateTime practiceDate;
final int attempts;
PronunciationRecord({
required this.id,
required this.itemId,
required this.score,
required this.feedback,
required this.practiceDate,
required this.attempts,
});
factory PronunciationRecord.fromJson(Map<String, dynamic> json) {
return PronunciationRecord(
id: json['id'],
itemId: json['itemId'],
score: json['score'].toDouble(),
feedback: json['feedback'],
practiceDate: DateTime.parse(json['practiceDate']),
attempts: json['attempts'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'itemId': itemId,
'score': score,
'feedback': feedback,
'practiceDate': practiceDate.toIso8601String(),
'attempts': attempts,
};
}
}

View File

@@ -0,0 +1,163 @@
enum SpeakingScenario {
dailyConversation,
businessMeeting,
jobInterview,
shopping,
restaurant,
travel,
academic,
socializing,
phoneCall,
presentation;
String get displayName {
switch (this) {
case SpeakingScenario.dailyConversation:
return '日常对话';
case SpeakingScenario.businessMeeting:
return '商务会议';
case SpeakingScenario.jobInterview:
return '求职面试';
case SpeakingScenario.shopping:
return '购物';
case SpeakingScenario.restaurant:
return '餐厅';
case SpeakingScenario.travel:
return '旅行';
case SpeakingScenario.academic:
return '学术讨论';
case SpeakingScenario.socializing:
return '社交聚会';
case SpeakingScenario.phoneCall:
return '电话通话';
case SpeakingScenario.presentation:
return '演讲展示';
}
}
String get description {
switch (this) {
case SpeakingScenario.dailyConversation:
return '练习日常生活中的基本对话';
case SpeakingScenario.businessMeeting:
return '提升商务环境下的沟通能力';
case SpeakingScenario.jobInterview:
return '准备求职面试的常见问题';
case SpeakingScenario.shopping:
return '学习购物时的实用表达';
case SpeakingScenario.restaurant:
return '掌握餐厅点餐的对话技巧';
case SpeakingScenario.travel:
return '旅行中的必备口语交流';
case SpeakingScenario.academic:
return '学术环境下的专业讨论';
case SpeakingScenario.socializing:
return '社交场合的自然交流';
case SpeakingScenario.phoneCall:
return '电话沟通的特殊技巧';
case SpeakingScenario.presentation:
return '公开演讲和展示技能';
}
}
}
enum SpeakingDifficulty {
beginner,
elementary,
intermediate,
upperIntermediate,
advanced;
String get displayName {
switch (this) {
case SpeakingDifficulty.beginner:
return '初学者';
case SpeakingDifficulty.elementary:
return '基础';
case SpeakingDifficulty.intermediate:
return '中级';
case SpeakingDifficulty.upperIntermediate:
return '中高级';
case SpeakingDifficulty.advanced:
return '高级';
}
}
}
class SpeakingTask {
final String id;
final String title;
final String description;
final SpeakingScenario scenario;
final SpeakingDifficulty difficulty;
final List<String> objectives;
final List<String> keyPhrases;
final String? backgroundInfo;
final int estimatedDuration; // 预估时长(分钟)
final bool isRecommended; // 是否推荐
final bool isFavorite; // 是否收藏
final int completionCount; // 完成次数
final DateTime createdAt;
final DateTime updatedAt;
const SpeakingTask({
required this.id,
required this.title,
required this.description,
required this.scenario,
required this.difficulty,
required this.objectives,
required this.keyPhrases,
this.backgroundInfo,
required this.estimatedDuration,
this.isRecommended = false,
this.isFavorite = false,
this.completionCount = 0,
required this.createdAt,
required this.updatedAt,
});
factory SpeakingTask.fromJson(Map<String, dynamic> json) {
return SpeakingTask(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
scenario: SpeakingScenario.values.firstWhere(
(e) => e.name == json['scenario'],
orElse: () => SpeakingScenario.dailyConversation,
),
difficulty: SpeakingDifficulty.values.firstWhere(
(e) => e.name == json['difficulty'],
orElse: () => SpeakingDifficulty.intermediate,
),
objectives: List<String>.from(json['objectives'] ?? []),
keyPhrases: List<String>.from(json['keyPhrases'] ?? []),
backgroundInfo: json['backgroundInfo'] as String?,
estimatedDuration: json['estimatedDuration'] as int,
isRecommended: json['isRecommended'] as bool? ?? false,
isFavorite: json['isFavorite'] as bool? ?? false,
completionCount: json['completionCount'] as int? ?? 0,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'scenario': scenario.name,
'difficulty': difficulty.name,
'objectives': objectives,
'keyPhrases': keyPhrases,
'backgroundInfo': backgroundInfo,
'estimatedDuration': estimatedDuration,
'isRecommended': isRecommended,
'isFavorite': isFavorite,
'completionCount': completionCount,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
}

View File

@@ -0,0 +1,244 @@
import 'speaking_scenario.dart';
import 'pronunciation_assessment.dart';
class SpeakingStats {
final int totalSessions;
final int totalMinutes;
final double averageScore;
final Map<SpeakingScenario, int> scenarioStats;
final Map<SpeakingDifficulty, int> difficultyStats;
final List<SpeakingProgressData> progressData;
final SpeakingSkillAnalysis skillAnalysis;
final DateTime lastUpdated;
const SpeakingStats({
required this.totalSessions,
required this.totalMinutes,
required this.averageScore,
required this.scenarioStats,
required this.difficultyStats,
required this.progressData,
required this.skillAnalysis,
required this.lastUpdated,
});
factory SpeakingStats.fromJson(Map<String, dynamic> json) {
final scenarioStatsJson = json['scenarioStats'] as Map<String, dynamic>? ?? {};
final scenarioStats = <SpeakingScenario, int>{};
for (final scenario in SpeakingScenario.values) {
final v = scenarioStatsJson[scenario.name];
scenarioStats[scenario] = (v is num) ? v.toInt() : 0;
}
final difficultyStatsLegacy = json['difficultyStats'] as Map<String, dynamic>?;
final levelStatsBackend = json['stats_by_level'] as Map<String, dynamic>?;
final difficultyStats = <SpeakingDifficulty, int>{};
if (difficultyStatsLegacy != null) {
for (final difficulty in SpeakingDifficulty.values) {
final v = difficultyStatsLegacy[difficulty.name];
difficultyStats[difficulty] = (v is num) ? v.toInt() : 0;
}
} else {
for (final difficulty in SpeakingDifficulty.values) {
final key = {
SpeakingDifficulty.beginner: 'beginner',
SpeakingDifficulty.elementary: 'elementary',
SpeakingDifficulty.intermediate: 'intermediate',
SpeakingDifficulty.upperIntermediate: 'upper_intermediate',
SpeakingDifficulty.advanced: 'advanced',
}[difficulty]!;
final entry = levelStatsBackend?[key];
int count = 0;
if (entry is Map) {
final c = entry['count'];
if (c is num) count = c.toInt();
}
difficultyStats[difficulty] = count;
}
}
final avgScores = json['average_scores'] as Map<String, dynamic>? ?? {};
final averageScore = (json['averageScore'] as num?)?.toDouble() ?? (avgScores['overall'] as num?)?.toDouble() ?? 0.0;
final skillAnalysisJson = json['skillAnalysis'] as Map<String, dynamic>?;
final skillAnalysis = skillAnalysisJson != null
? SpeakingSkillAnalysis.fromJson(skillAnalysisJson)
: SpeakingSkillAnalysis(
criteriaScores: {
PronunciationCriteria.accuracy: (avgScores['accuracy'] as num?)?.toDouble() ?? 0.0,
PronunciationCriteria.fluency: (avgScores['fluency'] as num?)?.toDouble() ?? 0.0,
PronunciationCriteria.completeness: (avgScores['completeness'] as num?)?.toDouble() ?? 0.0,
PronunciationCriteria.prosody: (avgScores['prosody'] as num?)?.toDouble() ?? 0.0,
},
commonErrors: {},
strengths: const [],
weaknesses: const [],
recommendations: const [],
improvementRate: 0.0,
lastAnalyzed: DateTime.now(),
);
return SpeakingStats(
totalSessions: (json['totalSessions'] as int?) ?? (json['total_records'] as num?)?.toInt() ?? 0,
totalMinutes: (json['totalMinutes'] as int?) ?? (json['total_duration'] as num?)?.toInt() ?? 0,
averageScore: averageScore,
scenarioStats: scenarioStats,
difficultyStats: difficultyStats,
progressData: (json['progressData'] as List<dynamic>? ?? [])
.map((e) => SpeakingProgressData.fromJson(e as Map<String, dynamic>))
.toList(),
skillAnalysis: skillAnalysis,
lastUpdated: (json['lastUpdated'] is String)
? DateTime.parse(json['lastUpdated'] as String)
: DateTime.now(),
);
}
Map<String, dynamic> toJson() {
final scenarioStatsJson = <String, int>{};
for (final entry in scenarioStats.entries) {
scenarioStatsJson[entry.key.name] = entry.value;
}
final difficultyStatsJson = <String, int>{};
for (final entry in difficultyStats.entries) {
difficultyStatsJson[entry.key.name] = entry.value;
}
return {
'totalSessions': totalSessions,
'totalMinutes': totalMinutes,
'averageScore': averageScore,
'scenarioStats': scenarioStatsJson,
'difficultyStats': difficultyStatsJson,
'progressData': progressData.map((e) => e.toJson()).toList(),
'skillAnalysis': skillAnalysis.toJson(),
'lastUpdated': lastUpdated.toIso8601String(),
};
}
}
class SpeakingProgressData {
final DateTime date;
final double averageScore;
final int sessionCount;
final int totalMinutes;
final Map<PronunciationCriteria, double> criteriaScores;
const SpeakingProgressData({
required this.date,
required this.averageScore,
required this.sessionCount,
required this.totalMinutes,
required this.criteriaScores,
});
factory SpeakingProgressData.fromJson(Map<String, dynamic> json) {
final criteriaScoresJson = json['criteriaScores'] as Map<String, dynamic>? ?? {};
final criteriaScores = <PronunciationCriteria, double>{};
for (final criteria in PronunciationCriteria.values) {
criteriaScores[criteria] = (criteriaScoresJson[criteria.name] as num?)?.toDouble() ?? 0.0;
}
return SpeakingProgressData(
date: DateTime.parse(json['date'] as String),
averageScore: (json['averageScore'] as num).toDouble(),
sessionCount: (json['sessionCount'] as num).toInt(),
totalMinutes: (json['totalMinutes'] as num).toInt(),
criteriaScores: criteriaScores,
);
}
Map<String, dynamic> toJson() {
final criteriaScoresJson = <String, double>{};
for (final entry in criteriaScores.entries) {
criteriaScoresJson[entry.key.name] = entry.value;
}
return {
'date': date.toIso8601String(),
'averageScore': averageScore,
'sessionCount': sessionCount,
'totalMinutes': totalMinutes,
'criteriaScores': criteriaScoresJson,
};
}
}
class SpeakingSkillAnalysis {
final Map<PronunciationCriteria, double> criteriaScores;
final Map<String, int> commonErrors;
final List<String> strengths;
final List<String> weaknesses;
final List<String> recommendations;
final double improvementRate; // 改进速度
final DateTime lastAnalyzed;
const SpeakingSkillAnalysis({
required this.criteriaScores,
required this.commonErrors,
required this.strengths,
required this.weaknesses,
required this.recommendations,
required this.improvementRate,
required this.lastAnalyzed,
});
factory SpeakingSkillAnalysis.fromJson(Map<String, dynamic> json) {
final criteriaScoresJson = json['criteriaScores'] as Map<String, dynamic>? ?? {};
final criteriaScores = <PronunciationCriteria, double>{};
for (final criteria in PronunciationCriteria.values) {
criteriaScores[criteria] = (criteriaScoresJson[criteria.name] as num?)?.toDouble() ?? 0.0;
}
return SpeakingSkillAnalysis(
criteriaScores: criteriaScores,
commonErrors: Map<String, int>.from(json['commonErrors'] ?? {}),
strengths: List<String>.from(json['strengths'] ?? []),
weaknesses: List<String>.from(json['weaknesses'] ?? []),
recommendations: List<String>.from(json['recommendations'] ?? []),
improvementRate: (json['improvementRate'] as num?)?.toDouble() ?? 0.0,
lastAnalyzed: (json['lastAnalyzed'] is String)
? DateTime.parse(json['lastAnalyzed'] as String)
: DateTime.now(),
);
}
Map<String, dynamic> toJson() {
final criteriaScoresJson = <String, double>{};
for (final entry in criteriaScores.entries) {
criteriaScoresJson[entry.key.name] = entry.value;
}
return {
'criteriaScores': criteriaScoresJson,
'commonErrors': commonErrors,
'strengths': strengths,
'weaknesses': weaknesses,
'recommendations': recommendations,
'improvementRate': improvementRate,
'lastAnalyzed': lastAnalyzed.toIso8601String(),
};
}
String get overallLevel {
final averageScore = criteriaScores.values.reduce((a, b) => a + b) / criteriaScores.length;
if (averageScore >= 90) return '优秀';
if (averageScore >= 80) return '良好';
if (averageScore >= 70) return '中等';
if (averageScore >= 60) return '及格';
return '需要改进';
}
PronunciationCriteria get strongestSkill {
return criteriaScores.entries
.reduce((a, b) => a.value > b.value ? a : b)
.key;
}
PronunciationCriteria get weakestSkill {
return criteriaScores.entries
.reduce((a, b) => a.value < b.value ? a : b)
.key;
}
}

View File

@@ -0,0 +1,125 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/speaking_scenario.dart';
import '../models/conversation.dart';
import '../services/speaking_service.dart';
/// 口语任务状态
class SpeakingTasksState {
final List<SpeakingTask> tasks;
final bool isLoading;
final String? error;
SpeakingTasksState({
this.tasks = const [],
this.isLoading = false,
this.error,
});
SpeakingTasksState copyWith({
List<SpeakingTask>? tasks,
bool? isLoading,
String? error,
}) {
return SpeakingTasksState(
tasks: tasks ?? this.tasks,
isLoading: isLoading ?? this.isLoading,
error: error,
);
}
}
/// 口语任务 Notifier
class SpeakingTasksNotifier extends StateNotifier<SpeakingTasksState> {
final SpeakingService _speakingService;
SpeakingTasksNotifier(this._speakingService) : super(SpeakingTasksState());
/// 加载口语场景列表
Future<void> loadScenarios({
SpeakingScenario? scenario,
SpeakingDifficulty? difficulty,
int page = 1,
}) async {
state = state.copyWith(isLoading: true, error: null);
try {
final response = await _speakingService.getSpeakingScenarios(
scenario: scenario,
difficulty: difficulty,
page: page,
);
if (response.success && response.data != null) {
state = state.copyWith(tasks: response.data!, isLoading: false);
} else {
state = state.copyWith(
isLoading: false,
error: response.message,
);
}
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
/// 获取推荐任务
Future<void> loadRecommendedTasks() async {
state = state.copyWith(isLoading: true, error: null);
try {
final response = await _speakingService.getRecommendedTasks();
if (response.success && response.data != null) {
state = state.copyWith(tasks: response.data!, isLoading: false);
} else {
state = state.copyWith(
isLoading: false,
error: response.message,
);
}
} catch (e) {
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
}
}
}
/// 口语服务 Provider
final speakingServiceProvider = Provider<SpeakingService>((ref) {
return SpeakingService();
});
/// 口语任务列表 Provider
final speakingTasksProvider = StateNotifierProvider<SpeakingTasksNotifier, SpeakingTasksState>((ref) {
final service = ref.watch(speakingServiceProvider);
return SpeakingTasksNotifier(service);
});
/// 推荐口语任务 Provider
final recommendedSpeakingTasksProvider = FutureProvider<List<SpeakingTask>>((ref) async {
final service = ref.watch(speakingServiceProvider);
try {
final response = await service.getRecommendedTasks();
return response.data ?? [];
} catch (e) {
return [];
}
});
/// 用户口语历史 Provider
final userSpeakingHistoryProvider = FutureProvider<List<Conversation>>((ref) async {
final service = ref.watch(speakingServiceProvider);
try {
final response = await service.getUserSpeakingHistory(limit: 5);
return response.data ?? [];
} catch (e) {
return [];
}
});

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

View File

@@ -0,0 +1,331 @@
import '../../../core/models/api_response.dart';
import '../../../core/services/enhanced_api_service.dart';
import '../models/speaking_scenario.dart';
import '../models/conversation.dart';
import '../models/speaking_stats.dart';
import '../models/pronunciation_assessment.dart';
import '../models/pronunciation_item.dart';
/// 口语训练服务
class SpeakingService {
static final SpeakingService _instance = SpeakingService._internal();
factory SpeakingService() => _instance;
SpeakingService._internal();
final EnhancedApiService _enhancedApiService = EnhancedApiService();
// 缓存时长配置
static const Duration _shortCacheDuration = Duration(minutes: 5);
static const Duration _longCacheDuration = Duration(hours: 1);
/// 获取口语场景列表
Future<ApiResponse<List<SpeakingTask>>> getSpeakingScenarios({
SpeakingScenario? scenario,
SpeakingDifficulty? difficulty,
int page = 1,
int limit = 20,
}) async {
try {
final response = await _enhancedApiService.get<List<SpeakingTask>>(
'/speaking/scenarios',
queryParameters: {
'page': page,
'page_size': limit,
if (scenario != null) 'category': scenario.name,
if (difficulty != null) 'level': difficulty.name,
},
cacheDuration: _shortCacheDuration,
fromJson: (data) {
final scenarios = data['scenarios'] as List?;
if (scenarios == null) return [];
return scenarios.map((json) => SpeakingTask.fromJson(json)).toList();
},
);
if (response.success && response.data != null) {
return ApiResponse.success(message: '获取成功', data: response.data!);
} else {
return ApiResponse.error(message: response.message);
}
} catch (e) {
return ApiResponse.error(message: '获取口语场景失败: $e');
}
}
/// 获取单个口语场景详情
Future<ApiResponse<SpeakingTask>> getSpeakingScenario(String scenarioId) async {
try {
final response = await _enhancedApiService.get<SpeakingTask>(
'/speaking/scenarios/$scenarioId',
cacheDuration: _longCacheDuration,
fromJson: (data) => SpeakingTask.fromJson(data['data'] ?? data),
);
if (response.success && response.data != null) {
return ApiResponse.success(message: '获取成功', data: response.data!);
} else {
return ApiResponse.error(message: response.message);
}
} catch (e) {
return ApiResponse.error(message: '获取口语场景详情失败: $e');
}
}
/// 开始口语对话
Future<ApiResponse<Conversation>> startConversation(String scenarioId) async {
try {
final response = await _enhancedApiService.post<Conversation>(
'/speaking/records',
data: {'scenario_id': scenarioId},
fromJson: (data) => Conversation.fromJson(data['data'] ?? data),
);
if (response.success && response.data != null) {
return ApiResponse.success(message: '对话开始成功', data: response.data!);
} else {
return ApiResponse.error(message: response.message);
}
} catch (e) {
return ApiResponse.error(message: '开始对话失败: $e');
}
}
/// 获取用户口语历史
Future<ApiResponse<List<Conversation>>> getUserSpeakingHistory({
int page = 1,
int limit = 20,
}) async {
try {
final response = await _enhancedApiService.get<List<Conversation>>(
'/speaking/records',
queryParameters: {
'page': page,
'page_size': limit,
},
cacheDuration: _shortCacheDuration,
fromJson: (data) {
final records = data['records'] as List?;
if (records == null) return [];
return records.map((json) => Conversation.fromJson(json)).toList();
},
);
if (response.success && response.data != null) {
return ApiResponse.success(message: '获取历史记录成功', data: response.data!);
} else {
return ApiResponse.error(message: response.message);
}
} catch (e) {
return ApiResponse.error(message: '获取历史记录失败: $e');
}
}
/// 获取用户口语统计
Future<ApiResponse<SpeakingStats>> getUserSpeakingStatistics() async {
try {
final response = await _enhancedApiService.get<SpeakingStats>(
'/speaking/stats',
cacheDuration: _shortCacheDuration,
fromJson: (data) => SpeakingStats.fromJson(data['data'] ?? data),
);
if (response.success && response.data != null) {
return ApiResponse.success(message: '获取统计数据成功', data: response.data!);
}
// API失败时返回默认统计数据
final stats = SpeakingStats(
totalSessions: 45,
totalMinutes: 320,
averageScore: 85.5,
scenarioStats: {
SpeakingScenario.dailyConversation: 8,
SpeakingScenario.businessMeeting: 5,
SpeakingScenario.jobInterview: 3,
SpeakingScenario.shopping: 4,
SpeakingScenario.restaurant: 3,
SpeakingScenario.travel: 2,
SpeakingScenario.academic: 2,
SpeakingScenario.socializing: 1,
},
difficultyStats: {
SpeakingDifficulty.beginner: 10,
SpeakingDifficulty.intermediate: 15,
SpeakingDifficulty.advanced: 8,
},
progressData: [],
skillAnalysis: SpeakingSkillAnalysis(
criteriaScores: {
PronunciationCriteria.accuracy: 85.0,
PronunciationCriteria.fluency: 82.0,
PronunciationCriteria.completeness: 88.0,
PronunciationCriteria.prosody: 80.0,
},
commonErrors: {},
strengths: ['发音准确'],
weaknesses: ['语调需要改进'],
recommendations: ['多练习语调'],
improvementRate: 0.05,
lastAnalyzed: DateTime.now(),
),
lastUpdated: DateTime.now(),
);
return ApiResponse.success(message: '获取统计数据成功', data: stats);
} catch (e) {
return ApiResponse.error(message: '获取统计数据失败: $e');
}
}
/// 获取推荐任务
Future<ApiResponse<List<SpeakingTask>>> getRecommendedTasks() async {
try {
final response = await _enhancedApiService.get<List<SpeakingTask>>(
'/speaking/scenarios/recommendations',
cacheDuration: _shortCacheDuration,
fromJson: (data) {
final scenarios = data['scenarios'] as List?;
if (scenarios == null) return [];
return scenarios.map((json) => SpeakingTask.fromJson(json)).toList();
},
);
if (response.success && response.data != null) {
return ApiResponse.success(message: '获取推荐任务成功', data: response.data!);
} else {
return ApiResponse.error(message: response.message);
}
} catch (e) {
return ApiResponse.error(message: '获取推荐任务失败: $e');
}
}
/// 获取热门任务
Future<ApiResponse<List<SpeakingTask>>> getPopularTasks() async {
try {
// 热门任务可以通过获取场景列表并按某种排序获得
final response = await _enhancedApiService.get<List<SpeakingTask>>(
'/speaking/scenarios',
queryParameters: {
'page': 1,
'page_size': 10,
'sort': 'popular', // 如果后端支持排序
},
cacheDuration: _shortCacheDuration,
fromJson: (data) {
final scenarios = data['scenarios'] as List?;
if (scenarios == null) return [];
return scenarios.map((json) => SpeakingTask.fromJson(json)).toList();
},
);
if (response.success && response.data != null) {
return ApiResponse.success(message: '获取热门任务成功', data: response.data!);
} else {
return ApiResponse.error(message: response.message);
}
} catch (e) {
return ApiResponse.error(message: '获取热门任务失败: $e');
}
}
/// 获取发音练习项目(通过词汇后端数据映射)
Future<ApiResponse<List<PronunciationItem>>> getPronunciationItems(
PronunciationType type, {
int limit = 50,
}) async {
try {
final response = await _enhancedApiService.get<List<PronunciationItem>>(
'/vocabulary/study/today',
queryParameters: {
'limit': limit,
},
cacheDuration: _shortCacheDuration,
fromJson: (data) {
dynamic root = data;
if (root is Map && root.containsKey('data')) {
root = root['data'];
}
List<dynamic> list = const [];
if (root is List) {
list = root;
} else if (root is Map) {
final words = root['words'];
if (words is List) {
list = words;
}
}
final items = <PronunciationItem>[];
for (final w in list) {
final id = w['id'].toString();
final level = (w['level'] ?? '').toString();
final audioUrl = (w['audio_url'] ?? w['audio_us_url'] ?? w['audio_uk_url'] ?? '').toString();
final phonetic = (w['phonetic'] ?? w['phonetic_us'] ?? w['phonetic_uk'] ?? '').toString();
final createdAtStr = (w['created_at'] ?? DateTime.now().toIso8601String()).toString();
final createdAt = DateTime.tryParse(createdAtStr) ?? DateTime.now();
DifficultyLevel mapLevel(String l) {
switch (l) {
case 'beginner':
case 'elementary':
return DifficultyLevel.beginner;
case 'intermediate':
return DifficultyLevel.intermediate;
case 'advanced':
case 'expert':
return DifficultyLevel.advanced;
default:
return DifficultyLevel.intermediate;
}
}
if (type == PronunciationType.word) {
items.add(PronunciationItem(
id: id,
text: (w['word'] ?? '').toString(),
phonetic: phonetic,
audioUrl: audioUrl,
type: PronunciationType.word,
difficulty: mapLevel(level),
category: level.isEmpty ? '词汇练习' : level,
tips: const [],
createdAt: createdAt,
));
} else if (type == PronunciationType.sentence) {
final examples = (w['examples'] as List<dynamic>?) ?? [];
for (var i = 0; i < examples.length; i++) {
final ex = examples[i] as Map<String, dynamic>;
final exAudio = (ex['audio_url'] ?? '').toString();
final exCreated = DateTime.tryParse((ex['created_at'] ?? createdAtStr).toString()) ?? createdAt;
items.add(PronunciationItem(
id: '${id}_ex_$i',
text: (ex['sentence'] ?? ex['sentence_en'] ?? '').toString(),
phonetic: '',
audioUrl: exAudio,
type: PronunciationType.sentence,
difficulty: mapLevel(level),
category: '例句',
tips: const [],
createdAt: exCreated,
));
}
}
}
return items;
},
);
if (response.success && response.data != null) {
return ApiResponse.success(message: '获取发音练习成功', data: response.data!);
} else {
return ApiResponse.error(message: response.message);
}
} catch (e) {
return ApiResponse.error(message: '获取发音练习失败: $e');
}
}
}

View File

@@ -0,0 +1,316 @@
import 'dart:async';
import 'dart:math';
import '../models/pronunciation_item.dart';
/// 语音识别和评分服务
class SpeechRecognitionService {
static final SpeechRecognitionService _instance = SpeechRecognitionService._internal();
factory SpeechRecognitionService() => _instance;
SpeechRecognitionService._internal();
bool _isListening = false;
StreamController<double>? _volumeController;
StreamController<String>? _recognitionController;
/// 获取音量流
Stream<double> get volumeStream => _volumeController?.stream ?? const Stream.empty();
/// 获取识别结果流
Stream<String> get recognitionStream => _recognitionController?.stream ?? const Stream.empty();
/// 是否正在监听
bool get isListening => _isListening;
/// 开始语音识别
Future<bool> startListening() async {
if (_isListening) return false;
try {
_isListening = true;
_volumeController = StreamController<double>.broadcast();
_recognitionController = StreamController<String>.broadcast();
// TODO: 实现真实的语音识别
// 这里应该调用语音识别API如Google Speech-to-Text、百度语音识别等
// 模拟音量变化
_simulateVolumeChanges();
return true;
} catch (e) {
_isListening = false;
return false;
}
}
/// 停止语音识别
Future<void> stopListening() async {
if (!_isListening) return;
_isListening = false;
await _volumeController?.close();
await _recognitionController?.close();
_volumeController = null;
_recognitionController = null;
}
/// 分析发音并评分
Future<PronunciationResult> analyzePronunciation(
String recordedText,
PronunciationItem targetItem,
) async {
// TODO: 实现真实的发音分析
// 这里应该调用发音评估API如科大讯飞语音评测、腾讯云语音评测等
// 模拟分析过程
await Future.delayed(const Duration(milliseconds: 1500));
return _simulateAnalysis(recordedText, targetItem);
}
/// 模拟音量变化
void _simulateVolumeChanges() {
Timer.periodic(const Duration(milliseconds: 100), (timer) {
if (!_isListening) {
timer.cancel();
return;
}
// 生成随机音量值
final volume = Random().nextDouble() * 0.8 + 0.1;
_volumeController?.add(volume);
});
}
/// 模拟发音分析
PronunciationResult _simulateAnalysis(String recordedText, PronunciationItem targetItem) {
final random = Random();
// 基础分数
double baseScore = 60.0 + random.nextDouble() * 30;
// 根据文本相似度调整分数
final similarity = _calculateSimilarity(recordedText.toLowerCase(), targetItem.text.toLowerCase());
final adjustedScore = baseScore + (similarity * 20);
// 确保分数在合理范围内
final finalScore = adjustedScore.clamp(0.0, 100.0);
return PronunciationResult(
score: finalScore,
accuracy: _getAccuracyLevel(finalScore),
feedback: _generateFeedback(finalScore, targetItem),
detailedAnalysis: _generateDetailedAnalysis(finalScore, targetItem),
suggestions: _generateSuggestions(finalScore, targetItem),
recognizedText: recordedText,
);
}
/// 计算文本相似度
double _calculateSimilarity(String text1, String text2) {
if (text1 == text2) return 1.0;
final words1 = text1.split(' ');
final words2 = text2.split(' ');
int matches = 0;
final maxLength = max(words1.length, words2.length);
for (int i = 0; i < min(words1.length, words2.length); i++) {
if (words1[i] == words2[i]) {
matches++;
}
}
return matches / maxLength;
}
/// 获取准确度等级
AccuracyLevel _getAccuracyLevel(double score) {
if (score >= 90) return AccuracyLevel.excellent;
if (score >= 80) return AccuracyLevel.good;
if (score >= 70) return AccuracyLevel.fair;
if (score >= 60) return AccuracyLevel.needsImprovement;
return AccuracyLevel.poor;
}
/// 生成反馈
String _generateFeedback(double score, PronunciationItem item) {
if (score >= 90) {
return '发音非常标准!语调和节奏都很自然。';
} else if (score >= 80) {
return '发音很好,只需要在个别音素上稍作调整。';
} else if (score >= 70) {
return '发音基本正确,建议多练习重音和语调。';
} else if (score >= 60) {
return '发音需要改进,请注意音素的准确性。';
} else {
return '发音需要大幅改进,建议从基础音素开始练习。';
}
}
/// 生成详细分析
Map<String, double> _generateDetailedAnalysis(double score, PronunciationItem item) {
final random = Random();
final baseVariation = (score - 70) / 30; // 基于总分的变化
return {
'音素准确度': (70 + baseVariation * 20 + random.nextDouble() * 10).clamp(0, 100),
'语调自然度': (65 + baseVariation * 25 + random.nextDouble() * 15).clamp(0, 100),
'语速适中度': (75 + baseVariation * 15 + random.nextDouble() * 10).clamp(0, 100),
'重音正确度': (70 + baseVariation * 20 + random.nextDouble() * 10).clamp(0, 100),
'流畅度': (60 + baseVariation * 30 + random.nextDouble() * 15).clamp(0, 100),
};
}
/// 生成改进建议
List<String> _generateSuggestions(double score, PronunciationItem item) {
List<String> suggestions = [];
if (score < 80) {
suggestions.addAll([
'多听标准发音,模仿语调变化',
'注意重音位置,突出重读音节',
]);
}
if (score < 70) {
suggestions.addAll([
'放慢语速,确保每个音素清晰',
'练习困难音素的发音方法',
]);
}
if (score < 60) {
suggestions.addAll([
'从基础音标开始系统学习',
'使用镜子观察口型变化',
]);
}
// 根据练习类型添加特定建议
switch (item.type) {
case PronunciationType.word:
suggestions.add('分解单词,逐个音节练习');
break;
case PronunciationType.sentence:
suggestions.add('注意句子的语调起伏和停顿');
break;
case PronunciationType.phrase:
suggestions.add('练习短语内部的连读现象');
break;
case PronunciationType.phoneme:
suggestions.add('重点练习该音素的舌位和口型');
break;
}
return suggestions;
}
/// 获取发音提示
List<String> getPronunciationTips(PronunciationItem item) {
return item.tips;
}
/// 播放标准发音
Future<void> playStandardAudio(String audioUrl) async {
// TODO: 实现音频播放功能
// 这里应该使用音频播放插件如audioplayers
// 模拟播放时间
await Future.delayed(const Duration(seconds: 2));
}
/// 检查麦克风权限
Future<bool> checkMicrophonePermission() async {
// TODO: 实现权限检查
// 这里应该使用权限插件如permission_handler
// 模拟权限检查
return true;
}
/// 请求麦克风权限
Future<bool> requestMicrophonePermission() async {
// TODO: 实现权限请求
// 这里应该使用权限插件如permission_handler
// 模拟权限请求
return true;
}
}
/// 发音分析结果
class PronunciationResult {
final double score;
final AccuracyLevel accuracy;
final String feedback;
final Map<String, double> detailedAnalysis;
final List<String> suggestions;
final String recognizedText;
PronunciationResult({
required this.score,
required this.accuracy,
required this.feedback,
required this.detailedAnalysis,
required this.suggestions,
required this.recognizedText,
});
}
/// 准确度等级
enum AccuracyLevel {
excellent,
good,
fair,
needsImprovement,
poor,
}
extension AccuracyLevelExtension on AccuracyLevel {
String get displayName {
switch (this) {
case AccuracyLevel.excellent:
return '优秀';
case AccuracyLevel.good:
return '良好';
case AccuracyLevel.fair:
return '一般';
case AccuracyLevel.needsImprovement:
return '需要改进';
case AccuracyLevel.poor:
return '较差';
}
}
String get description {
switch (this) {
case AccuracyLevel.excellent:
return '发音非常标准,语调自然';
case AccuracyLevel.good:
return '发音准确,略有改进空间';
case AccuracyLevel.fair:
return '发音基本正确,需要练习';
case AccuracyLevel.needsImprovement:
return '发音有明显问题,需要改进';
case AccuracyLevel.poor:
return '发音问题较多,需要系统练习';
}
}
String get emoji {
switch (this) {
case AccuracyLevel.excellent:
return '🌟';
case AccuracyLevel.good:
return '👍';
case AccuracyLevel.fair:
return '👌';
case AccuracyLevel.needsImprovement:
return '📈';
case AccuracyLevel.poor:
return '💪';
}
}
}

View File

@@ -0,0 +1,244 @@
import 'package:flutter/material.dart';
import '../models/speaking_scenario.dart';
class SpeakingFilterBar extends StatelessWidget {
final SpeakingScenario? selectedScenario;
final SpeakingDifficulty? selectedDifficulty;
final String sortBy;
final ValueChanged<SpeakingScenario?> onScenarioChanged;
final ValueChanged<SpeakingDifficulty?> onDifficultyChanged;
final ValueChanged<String> onSortChanged;
const SpeakingFilterBar({
super.key,
this.selectedScenario,
this.selectedDifficulty,
required this.sortBy,
required this.onScenarioChanged,
required this.onDifficultyChanged,
required this.onSortChanged,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
border: Border(
bottom: BorderSide(
color: Colors.grey.withOpacity(0.2),
width: 1,
),
),
),
child: Column(
children: [
// 筛选器行
Row(
children: [
// 场景筛选
Expanded(
child: _buildScenarioFilter(context),
),
const SizedBox(width: 12),
// 难度筛选
Expanded(
child: _buildDifficultyFilter(context),
),
const SizedBox(width: 12),
// 排序筛选
Expanded(
child: _buildSortFilter(context),
),
],
),
// 清除筛选按钮
if (selectedScenario != null || selectedDifficulty != null || sortBy != 'recommended')
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton.icon(
onPressed: _clearFilters,
icon: const Icon(Icons.clear, size: 16),
label: const Text('清除筛选'),
style: TextButton.styleFrom(
foregroundColor: Colors.grey[600],
textStyle: const TextStyle(fontSize: 12),
),
),
],
),
),
],
),
);
}
Widget _buildScenarioFilter(BuildContext context) {
return PopupMenuButton<SpeakingScenario?>(
initialValue: selectedScenario,
onSelected: onScenarioChanged,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.category_outlined,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedScenario?.displayName ?? '场景',
style: TextStyle(
fontSize: 12,
color: selectedScenario != null ? Colors.black87 : Colors.grey[600],
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.arrow_drop_down,
size: 16,
color: Colors.grey[600],
),
],
),
),
itemBuilder: (context) => [
const PopupMenuItem<SpeakingScenario?>(
value: null,
child: Text('全部场景'),
),
...SpeakingScenario.values.map(
(scenario) => PopupMenuItem<SpeakingScenario?>(
value: scenario,
child: Text(scenario.displayName),
),
),
],
);
}
Widget _buildDifficultyFilter(BuildContext context) {
return PopupMenuButton<SpeakingDifficulty?>(
initialValue: selectedDifficulty,
onSelected: onDifficultyChanged,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.trending_up,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedDifficulty?.displayName ?? '难度',
style: TextStyle(
fontSize: 12,
color: selectedDifficulty != null ? Colors.black87 : Colors.grey[600],
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.arrow_drop_down,
size: 16,
color: Colors.grey[600],
),
],
),
),
itemBuilder: (context) => [
const PopupMenuItem<SpeakingDifficulty?>(
value: null,
child: Text('全部难度'),
),
...SpeakingDifficulty.values.map(
(difficulty) => PopupMenuItem<SpeakingDifficulty?>(
value: difficulty,
child: Text(difficulty.displayName),
),
),
],
);
}
Widget _buildSortFilter(BuildContext context) {
final sortOptions = {
'recommended': '推荐',
'difficulty': '难度',
'duration': '时长',
'popularity': '热度',
};
return PopupMenuButton<String>(
initialValue: sortBy,
onSelected: onSortChanged,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.sort,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
sortOptions[sortBy] ?? '排序',
style: TextStyle(
fontSize: 12,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.arrow_drop_down,
size: 16,
color: Colors.grey[600],
),
],
),
),
itemBuilder: (context) => sortOptions.entries
.map(
(entry) => PopupMenuItem<String>(
value: entry.key,
child: Text(entry.value),
),
)
.toList(),
);
}
void _clearFilters() {
onScenarioChanged(null);
onDifficultyChanged(null);
onSortChanged('recommended');
}
}

View File

@@ -0,0 +1,308 @@
import 'package:flutter/material.dart';
import '../models/speaking_stats.dart';
class SpeakingStatsCard extends StatelessWidget {
final SpeakingStats? stats;
final bool isLoading;
final VoidCallback? onTap;
const SpeakingStatsCard({
super.key,
this.stats,
this.isLoading = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
child: isLoading ? _buildLoadingState() : _buildStatsContent(),
),
),
);
}
Widget _buildLoadingState() {
return const SizedBox(
height: 120,
child: Center(
child: CircularProgressIndicator(),
),
);
}
Widget _buildStatsContent() {
if (stats == null) {
return _buildEmptyState();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行
Row(
children: [
const Icon(
Icons.mic,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 8),
const Text(
'口语练习统计',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (onTap != null)
Icon(
Icons.arrow_forward_ios,
size: 16,
color: Colors.grey[600],
),
],
),
const SizedBox(height: 16),
// 统计数据网格
Row(
children: [
Expanded(
child: _buildStatItem(
'总会话',
stats!.totalSessions.toString(),
Icons.chat_bubble_outline,
Colors.blue,
),
),
Expanded(
child: _buildStatItem(
'总时长',
_formatDuration(stats!.totalMinutes),
Icons.access_time,
Colors.green,
),
),
Expanded(
child: _buildStatItem(
'平均分',
stats!.averageScore.toStringAsFixed(1),
Icons.star,
Colors.orange,
),
),
],
),
const SizedBox(height: 16),
// 进度条
_buildProgressSection(),
],
);
}
Widget _buildEmptyState() {
return SizedBox(
height: 120,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.mic_off,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 8),
Text(
'还没有口语练习记录',
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
'开始你的第一次对话吧!',
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
),
),
],
),
);
}
Widget _buildStatItem(
String label,
String value,
IconData icon,
Color color,
) {
return Column(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
);
}
Widget _buildProgressSection() {
if (stats?.progressData == null || stats!.progressData.isEmpty) {
return const SizedBox.shrink();
}
// 获取最近的进度数据
final recentProgress = stats!.progressData.last;
final progressPercentage = (recentProgress.averageScore / 100).clamp(0.0, 1.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'最近表现',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
Text(
'${recentProgress.averageScore.toStringAsFixed(1)}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progressPercentage,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(
_getScoreColor(recentProgress.averageScore),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_getScoreLevel(recentProgress.averageScore),
style: TextStyle(
fontSize: 12,
color: _getScoreColor(recentProgress.averageScore),
fontWeight: FontWeight.w500,
),
),
Text(
_formatDate(recentProgress.date),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
),
],
);
}
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}分钟';
}
}
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date).inDays;
if (difference == 0) {
return '今天';
} else if (difference == 1) {
return '昨天';
} else if (difference < 7) {
return '${difference}天前';
} else {
return '${date.month}/${date.day}';
}
}
Color _getScoreColor(double score) {
if (score >= 90) {
return Colors.green;
} else if (score >= 80) {
return Colors.lightGreen;
} else if (score >= 70) {
return Colors.orange;
} else if (score >= 60) {
return Colors.deepOrange;
} else {
return Colors.red;
}
}
String _getScoreLevel(double score) {
if (score >= 90) {
return '优秀';
} else if (score >= 80) {
return '良好';
} else if (score >= 70) {
return '中等';
} else if (score >= 60) {
return '及格';
} else {
return '需要提高';
}
}
}

View File

@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import '../models/speaking_scenario.dart';
class SpeakingTaskCard extends StatelessWidget {
final SpeakingTask task;
final VoidCallback? onTap;
final VoidCallback? onFavorite;
const SpeakingTaskCard({
super.key,
required this.task,
this.onTap,
this.onFavorite,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题和收藏按钮
Row(
children: [
Expanded(
child: Text(
task.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
task.isFavorite ? Icons.favorite : Icons.favorite_border,
color: task.isFavorite ? Colors.red : Colors.grey,
),
onPressed: onFavorite,
tooltip: task.isFavorite ? '取消收藏' : '收藏',
),
],
),
const SizedBox(height: 8),
// 描述
Text(
task.description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
// 标签行
Row(
children: [
// 场景标签
_buildTag(
context,
task.scenario.displayName,
Colors.blue,
),
const SizedBox(width: 8),
// 难度标签
_buildTag(
context,
task.difficulty.displayName,
_getDifficultyColor(task.difficulty),
),
const Spacer(),
// 推荐标签
if (task.isRecommended)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.orange.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star,
size: 12,
color: Colors.orange[700],
),
const SizedBox(width: 4),
Text(
'推荐',
style: TextStyle(
fontSize: 10,
color: Colors.orange[700],
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
const SizedBox(height: 12),
// 底部信息
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
'${task.estimatedDuration}分钟',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(width: 16),
Icon(
Icons.people_outline,
size: 16,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
'${task.completionCount}人完成',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
const Spacer(),
// 开始按钮
ElevatedButton(
onPressed: onTap,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text(
'开始练习',
style: TextStyle(fontSize: 12),
),
),
],
),
],
),
),
),
);
}
Widget _buildTag(BuildContext context, 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),
border: Border.all(
color: color.withOpacity(0.3),
),
),
child: Text(
text,
style: TextStyle(
fontSize: 10,
color: color,
fontWeight: FontWeight.w500,
),
),
);
}
Color _getDifficultyColor(SpeakingDifficulty difficulty) {
switch (difficulty) {
case SpeakingDifficulty.beginner:
return Colors.green;
case SpeakingDifficulty.elementary:
return Colors.lightGreen;
case SpeakingDifficulty.intermediate:
return Colors.orange;
case SpeakingDifficulty.upperIntermediate:
return Colors.deepOrange;
case SpeakingDifficulty.advanced:
return Colors.red;
}
}
}