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