Compare commits
5 Commits
d31340a05a
...
9345654cc1
| Author | SHA1 | Date |
|---|---|---|
|
|
9345654cc1 | |
|
|
fc49d3b6d7 | |
|
|
8c79fd8ef6 | |
|
|
7ce6057fd5 | |
|
|
d44cfe511a |
|
|
@ -72,7 +72,26 @@
|
|||
"Bash(do echo -n \"$ui: \")",
|
||||
"Bash(if grep -q \"$ui\" /tmp/system_ui_list.txt)",
|
||||
"Bash(fi)",
|
||||
"Bash(done)"
|
||||
"Bash(done)",
|
||||
"Bash(/Users/jettcheng1018/flutter/bin/flutter run -d 148D878C-62EB-4B60-9C04-2173EC0248BF)",
|
||||
"Bash(/Users/jettcheng1018/flutter/bin/flutter run -d Medium_Phone_API_36.0)",
|
||||
"Bash(/Users/jettcheng1018/flutter/bin/flutter emulators --launch Medium_Phone_API_36.0)",
|
||||
"Bash(dotnet run:*)",
|
||||
"Bash(dotnet --version)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(/bashes)",
|
||||
"Bash(pkill:*)",
|
||||
"Bash(say:*)",
|
||||
"Bash(./dl list)",
|
||||
"Bash(./dl task)",
|
||||
"Bash(./scripts/file_version_manager.sh:*)",
|
||||
"Bash(./scripts/archive_file.sh:*)",
|
||||
"Bash(./scripts/view_archives.sh:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(./sop/scripts/archive_file.sh:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
# 🎯 Drama Ling 任務清單
|
||||
|
||||
## 📋 當前任務
|
||||
|
||||
### 🔥 緊急任務
|
||||
- [ ] 🎨 **語法錯誤訂正頁面** - 完成學習閉環的關鍵頁面 (預估4-6小時)
|
||||
- 📄 參考: [學習閉環系統](projects/learning-loop-system.md)
|
||||
- [ ] 🎨 **表達不順訂正頁面** - 語音發音訂正界面 (預估4-6小時)
|
||||
- 📄 參考: [語音訂正系統](projects/voice-correction-system.md)
|
||||
- [ ] 💎 **UI_SubscriptionPlans設計** - 訂閱方案選擇頁面,核心商業功能 (預估6-8小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 💳 **UI_PaymentFlow設計** - 付費流程頁面,提升轉換率 (預估6-8小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] ⏰ **UI_TimedDialogue設計** - 300秒限時挑戰界面 (預估6-8小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
|
||||
### ⚠️ 重要任務
|
||||
- [ ] 📊 **資料庫schema設計** - 確定用戶表結構和關聯設計 (預估6-8小時)
|
||||
- 📄 參考: [資料庫設計專案](projects/database-design-project.md)
|
||||
- [ ] 🔐 **用戶認證流程細節** - 註冊、登入、權限管理流程 (預估4-6小時)
|
||||
- 📄 參考: [用戶認證系統](projects/user-auth-system.md)
|
||||
- [ ] 🏆 **UI_RankingDetail設計** - 排行榜詳情頁面,社群競爭功能 (預估4-6小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 🎁 **UI_RewardClaim設計** - 獎勵領取頁面,增強成就感 (預估3-4小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 📋 **UI_BonusMission_Main設計** - 每日任務主頁,提升活躍度 (預估4-6小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
|
||||
### 📝 一般任務
|
||||
- [ ] 🎨 **文字輸入彈窗界面** - 替換prompt()的完整UI (預估2-3小時)
|
||||
- 📄 參考: [UI組件系統](projects/ui-component-system.md)
|
||||
- [ ] 🔗 **頁面導航連接** - 完善用戶流程導航 (預估2-4小時)
|
||||
- 📄 參考: [導航系統](projects/navigation-system.md)
|
||||
- [ ] 🛠️ **特殊情況處理** - 錯誤狀態處理機制 (預估3-4小時)
|
||||
- 📄 參考: [錯誤處理系統](projects/error-handling-system.md)
|
||||
- [ ] 📚 **文檔格式統一** - 統一所有文檔格式規範 (預估2-3小時)
|
||||
- 📄 參考: [文檔規範](projects/documentation-standards.md)
|
||||
- [ ] 🏷️ **UI組件命名規範** - 建立統一命名標準 (預估1-2小時)
|
||||
- 📄 參考: [命名規範系統](projects/naming-convention-system.md)
|
||||
- [ ] 📚 **UI_ReviewCards設計** - 間隔複習卡片界面 (預估4-5小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 📊 **UI_ReviewProgress設計** - 複習進度統計頁面 (預估3-4小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 📅 **UI_ReviewSchedule設計** - 個人化複習排程頁面 (預估3-4小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 🏅 **UI_BadgeCollection設計** - 學習成就徽章收藏頁面 (預估3-4小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 💰 **UI_PurchasedContent設計** - 已購買內容管理頁面 (預估3-4小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 📺 **UI_AdOffer設計** - 廣告獎勵邀請頁面 (預估2-3小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 🎬 **UI_AdViewing設計** - 廣告觀看過程界面 (預估2-3小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
|
||||
### 💡 未來想法
|
||||
- [ ] 🔍 **UI功能重複評估** - 分析Result相關UI合併可能性
|
||||
- [ ] 🎨 **應用圖標和啟動畫面** - 品牌視覺設計
|
||||
- [ ] ❌ **錯誤處理UI組** - 錯誤、載入、網路異常、維護公告頁面 (預估6-8小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
- [ ] 🎨 **UI設計一致性檢查** - 17個新UI與71個現有UI的統一性檢查 (預估4-6小時)
|
||||
- 📄 參考: [UI設計任務清單](projects/ui-design-tasks.md)
|
||||
|
||||
---
|
||||
|
||||
## 📊 快速統計
|
||||
|
||||
**當前狀態**:
|
||||
- 🔥 緊急: 5個任務 (+3個UI設計)
|
||||
- ⚠️ 重要: 5個任務 (+3個UI設計)
|
||||
- 📝 一般: 12個任務 (+7個UI設計)
|
||||
- 💡 想法: 4個任務 (+2個UI設計)
|
||||
|
||||
**預估工作量**: 總計 98-132 小時 (包含17個UI設計任務)
|
||||
|
||||
---
|
||||
|
||||
## 📚 已完成任務 (最近10個)
|
||||
|
||||
### 2025-09-09 完成
|
||||
- [x] ✅ **CLAUDE.md文檔修正** - 修正章節重複、日期過時等結構性問題 ✅ (2025-09-09)
|
||||
- 📄 專案文檔: [CLAUDE.md問題分析](archive/2025-09-09/225744_claude-md-issues-analysis.md)
|
||||
- 🔧 解決內容: 章節編號重複、日期過時、工作流程不一致、檔案參考錯誤、問題管理流程混淆
|
||||
- [x] ✅ **API文檔系統重組** - 移動swagger-ui.html到docs/api/ (已完成)
|
||||
- [x] ✅ **專案管理系統整合** - 完成三層架構設計 (已完成)
|
||||
- [x] ✅ **情境學習界面實現** - 完成vocabulary.html的情境學習功能 (已完成)
|
||||
|
||||
### 2025-09-08 完成
|
||||
- [x] ✅ **02_design規格完善** - 建立5個核心功能詳細規格文檔 (已完成)
|
||||
- [x] ✅ **API模組化文檔** - 完成7個API模組建立 (已完成)
|
||||
- [x] ✅ **UI設計缺漏修復** - 100%完成40個缺失UI設計 (已完成)
|
||||
- [x] ✅ **系統整合指南** - 完成INTEGRATION_GUIDE.md (已完成)
|
||||
- [x] ✅ **工具系統更新** - ./dl命令支援新任務管理系統 (已完成)
|
||||
- [x] ✅ **CLAUDE工作指南更新** - 整合協作標準和通知系統 (已完成)
|
||||
- [x] ✅ **問題追蹤系統建立** - ISSUES.md完整問題管理機制 (已完成)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 系統使用指南
|
||||
|
||||
### 查看任務
|
||||
```bash
|
||||
./dl task # 打開此任務管理文件
|
||||
./dl status # 查看任務統計
|
||||
./dl list # 快速查看待辦清單
|
||||
```
|
||||
|
||||
### 工作模式
|
||||
1. **討論階段**: 與Claude自由討論需求和想法
|
||||
2. **記錄階段**: Claude自動記錄任務到此系統,並創建對應專案詳細文檔
|
||||
3. **執行階段**: 查看此文件選擇任務批量執行
|
||||
4. **完成階段**: 標記任務完成 [x],任務自動移至已完成區域
|
||||
|
||||
### 任務格式說明
|
||||
```markdown
|
||||
- [ ] 🎯 **任務名稱** - 簡短描述 (預估時間)
|
||||
- 📄 參考: [專案詳細文檔](projects/project-name.md)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**建立日期**: 2025-09-09
|
||||
**最後更新**: 2025-09-09 (整合UI設計任務)
|
||||
**維護者**: Claude Code & Drama Ling Team
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI設計專案說明
|
||||
|
||||
本任務清單已整合 `projects/ui-design-tasks.md` 中的17個UI設計任務,分佈如下:
|
||||
|
||||
### 🔥 第一優先級 - 核心商業功能 (3個)
|
||||
- UI_SubscriptionPlans, UI_PaymentFlow, UI_TimedDialogue
|
||||
|
||||
### ⚠️ 第二優先級 - 學習體驗增強 (3個)
|
||||
- UI_RankingDetail, UI_RewardClaim, UI_BonusMission_Main
|
||||
|
||||
### 📝 第三優先級 - 學習功能完善 (7個)
|
||||
- UI_ReviewCards, UI_ReviewProgress, UI_ReviewSchedule, UI_BadgeCollection, UI_PurchasedContent, UI_AdOffer, UI_AdViewing
|
||||
|
||||
### 💡 第四優先級 - 輔助功能 (4個)
|
||||
- 錯誤處理UI組, UI設計一致性檢查
|
||||
|
||||
**設計目標**: 完成剩餘17個UI介面,從71/88 (81%) 達成100%完整覆蓋
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# Drama Ling Applications
|
||||
|
||||
本目錄包含 Drama Ling 專案的所有應用程式。
|
||||
|
||||
## 應用程式架構
|
||||
|
||||
```
|
||||
apps/
|
||||
├── web/ # Vue.js Web 前端應用
|
||||
├── mobile/ # Flutter 移動端應用
|
||||
└── backend/ # .NET Core 後端 API
|
||||
```
|
||||
|
||||
## 開發狀態
|
||||
|
||||
| 應用程式 | 狀態 | 技術棧 | 說明 |
|
||||
|---------|------|--------|------|
|
||||
| Web | ✅ 開發中 | Vue.js + Quasar | Web 前端界面 |
|
||||
| Mobile | ✅ 開發中 | Flutter + Riverpod | 跨平台移動應用 |
|
||||
| Backend | ✅ 開發中 | .NET Core + EF Core | REST API 服務 |
|
||||
|
||||
## 開發指南
|
||||
|
||||
各應用程式的詳細開發文檔請參考:
|
||||
- 技術文檔:`../docs/04_technical/`
|
||||
- 專案規格:`../projects/`
|
||||
- 任務管理:`../TASKS.md`
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Drama Ling Backend API
|
||||
|
||||
.NET Core 後端 API 服務
|
||||
|
||||
## 技術棧
|
||||
- **.NET 8**: 跨平台框架
|
||||
- **ASP.NET Core Web API**: RESTful API
|
||||
- **Entity Framework Core**: ORM 資料庫存取
|
||||
- **PostgreSQL**: 主要資料庫
|
||||
- **Redis**: 快取和會話管理
|
||||
- **JWT**: 身份驗證
|
||||
|
||||
## 專案結構
|
||||
```
|
||||
backend/
|
||||
├── DramaLing.API/ # Web API 專案
|
||||
├── DramaLing.Application/ # 應用服務層
|
||||
├── DramaLing.Core/ # 領域模型層
|
||||
├── DramaLing.Infrastructure/ # 基礎設施層
|
||||
├── DramaLing.Tests/ # 測試專案
|
||||
└── DramaLing.sln # 解決方案檔
|
||||
```
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 1. 安裝相依套件
|
||||
```bash
|
||||
dotnet restore
|
||||
```
|
||||
|
||||
### 2. 設定資料庫
|
||||
```bash
|
||||
# 建立資料庫
|
||||
dotnet ef database update --project DramaLing.Infrastructure --startup-project DramaLing.API
|
||||
```
|
||||
|
||||
### 3. 啟動開發服務器
|
||||
```bash
|
||||
dotnet run --project DramaLing.API
|
||||
# API: http://localhost:5000
|
||||
# Swagger: http://localhost:5000
|
||||
```
|
||||
|
||||
## 開發指南
|
||||
詳細開發文檔請參考:`../../docs/04_technical/`
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# Drama Ling Mobile App
|
||||
|
||||
Flutter 移動端應用程式
|
||||
|
||||
## 技術棧
|
||||
- **Flutter 3.16+**: 跨平台框架
|
||||
- **Dart 3.0+**: 程式語言
|
||||
- **Riverpod**: 狀態管理
|
||||
- **Go Router**: 導航路由
|
||||
- **Dio + Retrofit**: HTTP 客戶端
|
||||
- **Hive**: 本地資料存儲
|
||||
- **Material 3**: UI 設計系統
|
||||
|
||||
## 專案結構
|
||||
```
|
||||
mobile/
|
||||
├── lib/
|
||||
│ ├── core/ # 核心功能 (常數、工具、服務)
|
||||
│ ├── features/ # 功能模組 (認證、學習、對話等)
|
||||
│ └── shared/ # 共用組件 (Widget、模型、Provider)
|
||||
└── pubspec.yaml # Flutter 專案配置
|
||||
```
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 1. 安裝相依套件
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### 2. 程式碼生成
|
||||
```bash
|
||||
dart run build_runner build
|
||||
```
|
||||
|
||||
### 3. 啟動應用
|
||||
```bash
|
||||
flutter run
|
||||
# 需要模擬器或實體裝置
|
||||
```
|
||||
|
||||
## 開發指南
|
||||
詳細開發文檔請參考:`../../docs/04_technical/`
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -12,12 +12,18 @@ PODS:
|
|||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- speech_to_text (0.0.1):
|
||||
- Flutter
|
||||
- Try
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- Try (2.1.1)
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||
|
|
@ -26,9 +32,15 @@ DEPENDENCIES:
|
|||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- speech_to_text (from `.symlinks/plugins/speech_to_text/ios`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- Try
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/ios"
|
||||
|
|
@ -42,8 +54,12 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/just_audio/darwin"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
speech_to_text:
|
||||
:path: ".symlinks/plugins/speech_to_text/ios"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
|
||||
|
|
@ -54,8 +70,11 @@ SPEC CHECKSUMS:
|
|||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
speech_to_text: b43a7d99aef037bd758ed8e45d79bbac035d2dfe
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
Try: 5ef669ae832617b3cee58cb2c6f99fb767a4ff96
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
|
||||
|
|
@ -199,6 +199,7 @@
|
|||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
B7DA006F490B39DC5DD7D624 /* [CP] Embed Pods Frameworks */,
|
||||
7328AAE839104E6E980FA1B3 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
|
|
@ -308,6 +309,23 @@
|
|||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
7328AAE839104E6E980FA1B3 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 282 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 704 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 586 B |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 762 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 68 B |
|
|
@ -0,0 +1,355 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:speech_to_text/speech_recognition_error.dart';
|
||||
import 'package:speech_to_text/speech_recognition_result.dart';
|
||||
import 'package:speech_to_text/speech_to_text.dart';
|
||||
|
||||
/// AI語音識別服務
|
||||
///
|
||||
/// 提供完整的語音識別功能,支援:
|
||||
/// - 實時語音轉文字
|
||||
/// - 多語言支援(中文、英文)
|
||||
/// - 音頻權限管理
|
||||
/// - 錯誤處理與重試機制
|
||||
/// - 音量監測
|
||||
class VoiceRecognitionService {
|
||||
static final VoiceRecognitionService _instance = VoiceRecognitionService._internal();
|
||||
factory VoiceRecognitionService() => _instance;
|
||||
VoiceRecognitionService._internal();
|
||||
|
||||
final SpeechToText _speechToText = SpeechToText();
|
||||
|
||||
// 語音識別狀態
|
||||
bool _isInitialized = false;
|
||||
bool _isListening = false;
|
||||
bool _isAvailable = false;
|
||||
|
||||
// 識別結果回調
|
||||
final StreamController<VoiceRecognitionResult> _resultController =
|
||||
StreamController<VoiceRecognitionResult>.broadcast();
|
||||
|
||||
// 音量回調
|
||||
final StreamController<double> _soundLevelController =
|
||||
StreamController<double>.broadcast();
|
||||
|
||||
// 狀態回調
|
||||
final StreamController<VoiceRecognitionState> _stateController =
|
||||
StreamController<VoiceRecognitionState>.broadcast();
|
||||
|
||||
// 支援的語言
|
||||
static const Map<String, String> supportedLanguages = {
|
||||
'zh-TW': '繁體中文',
|
||||
'zh-CN': '簡體中文',
|
||||
'en-US': 'English (US)',
|
||||
'en-GB': 'English (UK)',
|
||||
};
|
||||
|
||||
// Getters
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isListening => _isListening;
|
||||
bool get isAvailable => _isAvailable;
|
||||
|
||||
// Streams
|
||||
Stream<VoiceRecognitionResult> get resultStream => _resultController.stream;
|
||||
Stream<double> get soundLevelStream => _soundLevelController.stream;
|
||||
Stream<VoiceRecognitionState> get stateStream => _stateController.stream;
|
||||
|
||||
/// 初始化語音識別服務
|
||||
Future<bool> initialize() async {
|
||||
try {
|
||||
// 檢查並請求麥克風權限
|
||||
final permissionStatus = await _requestMicrophonePermission();
|
||||
if (!permissionStatus) {
|
||||
debugPrint('VoiceRecognitionService: 麥克風權限被拒絕');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 初始化語音識別引擎
|
||||
_isAvailable = await _speechToText.initialize(
|
||||
onError: _onError,
|
||||
onStatus: _onStatus,
|
||||
);
|
||||
|
||||
if (_isAvailable) {
|
||||
_isInitialized = true;
|
||||
_stateController.add(VoiceRecognitionState.initialized);
|
||||
debugPrint('VoiceRecognitionService: 初始化成功');
|
||||
return true;
|
||||
} else {
|
||||
debugPrint('VoiceRecognitionService: 語音識別不可用');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('VoiceRecognitionService: 初始化失敗 - $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 開始語音識別
|
||||
Future<bool> startListening({
|
||||
String languageId = 'zh-TW',
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
bool partialResults = true,
|
||||
}) async {
|
||||
if (!_isInitialized || !_isAvailable) {
|
||||
debugPrint('VoiceRecognitionService: 服務未初始化或不可用');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_isListening) {
|
||||
debugPrint('VoiceRecognitionService: 已在監聽中');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await _speechToText.listen(
|
||||
onResult: _onResult,
|
||||
listenFor: timeout,
|
||||
pauseFor: const Duration(seconds: 3),
|
||||
partialResults: partialResults,
|
||||
localeId: languageId,
|
||||
onSoundLevelChange: _onSoundLevelChange,
|
||||
listenMode: ListenMode.confirmation,
|
||||
);
|
||||
|
||||
_isListening = true;
|
||||
_stateController.add(VoiceRecognitionState.listening);
|
||||
debugPrint('VoiceRecognitionService: 開始監聽');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('VoiceRecognitionService: 開始監聽失敗 - $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止語音識別
|
||||
Future<void> stopListening() async {
|
||||
if (!_isListening) return;
|
||||
|
||||
try {
|
||||
await _speechToText.stop();
|
||||
_isListening = false;
|
||||
_stateController.add(VoiceRecognitionState.stopped);
|
||||
debugPrint('VoiceRecognitionService: 停止監聽');
|
||||
} catch (e) {
|
||||
debugPrint('VoiceRecognitionService: 停止監聽失敗 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消語音識別
|
||||
Future<void> cancel() async {
|
||||
if (!_isListening) return;
|
||||
|
||||
try {
|
||||
await _speechToText.cancel();
|
||||
_isListening = false;
|
||||
_stateController.add(VoiceRecognitionState.cancelled);
|
||||
debugPrint('VoiceRecognitionService: 取消監聽');
|
||||
} catch (e) {
|
||||
debugPrint('VoiceRecognitionService: 取消監聽失敗 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 獲取支援的語言
|
||||
Future<List<LocaleName>> getAvailableLanguages() async {
|
||||
if (!_isInitialized) return [];
|
||||
return await _speechToText.locales();
|
||||
}
|
||||
|
||||
/// 檢查特定語言是否支援
|
||||
Future<bool> isLanguageSupported(String languageId) async {
|
||||
final locales = await getAvailableLanguages();
|
||||
return locales.any((locale) => locale.localeId == languageId);
|
||||
}
|
||||
|
||||
/// 請求麥克風權限
|
||||
Future<bool> _requestMicrophonePermission() async {
|
||||
final status = await Permission.microphone.status;
|
||||
|
||||
if (status.isGranted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status.isDenied) {
|
||||
final result = await Permission.microphone.request();
|
||||
return result.isGranted;
|
||||
}
|
||||
|
||||
if (status.isPermanentlyDenied) {
|
||||
await openAppSettings();
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 處理識別結果
|
||||
void _onResult(SpeechRecognitionResult result) {
|
||||
final voiceResult = VoiceRecognitionResult(
|
||||
recognizedWords: result.recognizedWords,
|
||||
confidence: result.confidence,
|
||||
isFinal: result.finalResult,
|
||||
alternatives: result.alternates.map((alt) =>
|
||||
VoiceAlternative(
|
||||
text: alt.recognizedWords,
|
||||
confidence: alt.confidence,
|
||||
)
|
||||
).toList(),
|
||||
);
|
||||
|
||||
_resultController.add(voiceResult);
|
||||
|
||||
if (result.finalResult) {
|
||||
debugPrint('VoiceRecognitionService: 最終結果 - ${result.recognizedWords}');
|
||||
} else {
|
||||
debugPrint('VoiceRecognitionService: 部分結果 - ${result.recognizedWords}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 處理錯誤
|
||||
void _onError(SpeechRecognitionError error) {
|
||||
debugPrint('VoiceRecognitionService: 錯誤 - ${error.errorMsg}');
|
||||
|
||||
final errorType = _mapErrorType(error.errorMsg);
|
||||
_stateController.add(VoiceRecognitionState.error(errorType, error.errorMsg));
|
||||
|
||||
_isListening = false;
|
||||
}
|
||||
|
||||
/// 處理狀態變化
|
||||
void _onStatus(String status) {
|
||||
debugPrint('VoiceRecognitionService: 狀態變化 - $status');
|
||||
|
||||
switch (status) {
|
||||
case 'listening':
|
||||
_isListening = true;
|
||||
_stateController.add(VoiceRecognitionState.listening);
|
||||
break;
|
||||
case 'notListening':
|
||||
_isListening = false;
|
||||
_stateController.add(VoiceRecognitionState.stopped);
|
||||
break;
|
||||
case 'done':
|
||||
_isListening = false;
|
||||
_stateController.add(VoiceRecognitionState.completed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 處理音量變化
|
||||
void _onSoundLevelChange(double level) {
|
||||
_soundLevelController.add(level);
|
||||
}
|
||||
|
||||
/// 映射錯誤類型
|
||||
VoiceRecognitionErrorType _mapErrorType(String errorMsg) {
|
||||
if (errorMsg.contains('network')) {
|
||||
return VoiceRecognitionErrorType.network;
|
||||
} else if (errorMsg.contains('audio')) {
|
||||
return VoiceRecognitionErrorType.audio;
|
||||
} else if (errorMsg.contains('permission')) {
|
||||
return VoiceRecognitionErrorType.permission;
|
||||
} else if (errorMsg.contains('timeout')) {
|
||||
return VoiceRecognitionErrorType.timeout;
|
||||
} else {
|
||||
return VoiceRecognitionErrorType.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理資源
|
||||
void dispose() {
|
||||
_resultController.close();
|
||||
_soundLevelController.close();
|
||||
_stateController.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 語音識別結果
|
||||
class VoiceRecognitionResult {
|
||||
final String recognizedWords;
|
||||
final double confidence;
|
||||
final bool isFinal;
|
||||
final List<VoiceAlternative> alternatives;
|
||||
|
||||
VoiceRecognitionResult({
|
||||
required this.recognizedWords,
|
||||
required this.confidence,
|
||||
required this.isFinal,
|
||||
this.alternatives = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoiceRecognitionResult(words: $recognizedWords, confidence: $confidence, isFinal: $isFinal)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 語音識別替代結果
|
||||
class VoiceAlternative {
|
||||
final String text;
|
||||
final double confidence;
|
||||
|
||||
VoiceAlternative({
|
||||
required this.text,
|
||||
required this.confidence,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'VoiceAlternative(text: $text, confidence: $confidence)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 語音識別狀態
|
||||
class VoiceRecognitionState {
|
||||
final VoiceRecognitionStatus status;
|
||||
final VoiceRecognitionErrorType? errorType;
|
||||
final String? errorMessage;
|
||||
|
||||
VoiceRecognitionState._(this.status, [this.errorType, this.errorMessage]);
|
||||
|
||||
static VoiceRecognitionState get uninitialized =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.uninitialized);
|
||||
|
||||
static VoiceRecognitionState get initialized =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.initialized);
|
||||
|
||||
static VoiceRecognitionState get listening =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.listening);
|
||||
|
||||
static VoiceRecognitionState get stopped =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.stopped);
|
||||
|
||||
static VoiceRecognitionState get completed =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.completed);
|
||||
|
||||
static VoiceRecognitionState get cancelled =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.cancelled);
|
||||
|
||||
static VoiceRecognitionState error(VoiceRecognitionErrorType errorType, String message) =>
|
||||
VoiceRecognitionState._(VoiceRecognitionStatus.error, errorType, message);
|
||||
|
||||
bool get hasError => status == VoiceRecognitionStatus.error;
|
||||
}
|
||||
|
||||
/// 語音識別狀態枚舉
|
||||
enum VoiceRecognitionStatus {
|
||||
uninitialized,
|
||||
initialized,
|
||||
listening,
|
||||
stopped,
|
||||
completed,
|
||||
cancelled,
|
||||
error,
|
||||
}
|
||||
|
||||
/// 語音識別錯誤類型
|
||||
enum VoiceRecognitionErrorType {
|
||||
network,
|
||||
audio,
|
||||
permission,
|
||||
timeout,
|
||||
unknown,
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../../features/auth/screens/login_screen.dart';
|
||||
import '../../features/auth/screens/register_screen.dart';
|
||||
import '../../features/learning/screens/home_screen.dart';
|
||||
import '../../features/dialogue/screens/dialogue_main_screen.dart';
|
||||
import '../../shared/providers/auth_provider.dart';
|
||||
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
|
|
@ -52,9 +53,17 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||
),
|
||||
GoRoute(
|
||||
path: '/dialogue',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: Center(child: Text('對話練習頁面')),
|
||||
),
|
||||
builder: (context, state) {
|
||||
final scenarioId = state.uri.queryParameters['scenarioId'] ?? 'restaurant_001';
|
||||
final levelId = state.uri.queryParameters['levelId'] ?? 'level_001';
|
||||
final isTimeChallenge = state.uri.queryParameters['timeChallenge'] == 'true';
|
||||
|
||||
return DialogueMainScreen(
|
||||
scenarioId: scenarioId,
|
||||
levelId: levelId,
|
||||
isTimeChallenge: isTimeChallenge,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/challenge',
|
||||
|
|
@ -0,0 +1,547 @@
|
|||
/// 對話場景模型
|
||||
class DialogueScene {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final String backgroundImageUrl;
|
||||
final String characterId;
|
||||
final String difficultyLevel;
|
||||
final List<String> tags;
|
||||
final Map<String, dynamic> metadata;
|
||||
|
||||
DialogueScene({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.backgroundImageUrl,
|
||||
required this.characterId,
|
||||
required this.difficultyLevel,
|
||||
this.tags = const [],
|
||||
this.metadata = const {},
|
||||
});
|
||||
|
||||
factory DialogueScene.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueScene(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
backgroundImageUrl: json['backgroundImageUrl'] as String,
|
||||
characterId: json['characterId'] as String,
|
||||
difficultyLevel: json['difficultyLevel'] as String,
|
||||
tags: List<String>.from(json['tags'] ?? []),
|
||||
metadata: json['metadata'] as Map<String, dynamic>? ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'backgroundImageUrl': backgroundImageUrl,
|
||||
'characterId': characterId,
|
||||
'difficultyLevel': difficultyLevel,
|
||||
'tags': tags,
|
||||
'metadata': metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 對話角色模型
|
||||
class DialogueCharacter {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final String avatarUrl;
|
||||
final String personality;
|
||||
final String role;
|
||||
final String background;
|
||||
final List<String> specialities;
|
||||
final Map<String, String> localizedNames;
|
||||
|
||||
DialogueCharacter({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.avatarUrl,
|
||||
required this.personality,
|
||||
required this.role,
|
||||
required this.background,
|
||||
this.specialities = const [],
|
||||
this.localizedNames = const {},
|
||||
});
|
||||
|
||||
factory DialogueCharacter.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueCharacter(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
avatarUrl: json['avatarUrl'] as String,
|
||||
personality: json['personality'] as String,
|
||||
role: json['role'] as String,
|
||||
background: json['background'] as String,
|
||||
specialities: List<String>.from(json['specialities'] ?? []),
|
||||
localizedNames: Map<String, String>.from(json['localizedNames'] ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'avatarUrl': avatarUrl,
|
||||
'personality': personality,
|
||||
'role': role,
|
||||
'background': background,
|
||||
'specialities': specialities,
|
||||
'localizedNames': localizedNames,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 對話消息模型
|
||||
class DialogueMessage {
|
||||
final String id;
|
||||
final String content;
|
||||
final bool isUser;
|
||||
final DateTime timestamp;
|
||||
final DialogueMessageType type;
|
||||
final Map<String, dynamic>? metadata;
|
||||
final String? audioUrl;
|
||||
final double? confidence;
|
||||
|
||||
DialogueMessage({
|
||||
required this.id,
|
||||
required this.content,
|
||||
required this.isUser,
|
||||
required this.timestamp,
|
||||
this.type = DialogueMessageType.text,
|
||||
this.metadata,
|
||||
this.audioUrl,
|
||||
this.confidence,
|
||||
});
|
||||
|
||||
factory DialogueMessage.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueMessage(
|
||||
id: json['id'] as String,
|
||||
content: json['content'] as String,
|
||||
isUser: json['isUser'] as bool,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
type: DialogueMessageType.values.firstWhere(
|
||||
(e) => e.toString() == 'DialogueMessageType.${json['type']}',
|
||||
orElse: () => DialogueMessageType.text,
|
||||
),
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
audioUrl: json['audioUrl'] as String?,
|
||||
confidence: json['confidence'] as double?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'content': content,
|
||||
'isUser': isUser,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'type': type.toString().split('.').last,
|
||||
'metadata': metadata,
|
||||
'audioUrl': audioUrl,
|
||||
'confidence': confidence,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 對話消息類型
|
||||
enum DialogueMessageType {
|
||||
text,
|
||||
audio,
|
||||
system,
|
||||
hint,
|
||||
}
|
||||
|
||||
/// 對話任務模型
|
||||
class DialogueTask {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final DialogueTaskType type;
|
||||
final Map<String, dynamic> requirements;
|
||||
final double progress;
|
||||
final bool isCompleted;
|
||||
final int maxAttempts;
|
||||
final int currentAttempts;
|
||||
final String? completionMessage;
|
||||
|
||||
DialogueTask({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.type,
|
||||
required this.requirements,
|
||||
this.progress = 0.0,
|
||||
this.isCompleted = false,
|
||||
this.maxAttempts = 3,
|
||||
this.currentAttempts = 0,
|
||||
this.completionMessage,
|
||||
});
|
||||
|
||||
factory DialogueTask.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueTask(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
type: DialogueTaskType.values.firstWhere(
|
||||
(e) => e.toString() == 'DialogueTaskType.${json['type']}',
|
||||
orElse: () => DialogueTaskType.conversation,
|
||||
),
|
||||
requirements: json['requirements'] as Map<String, dynamic>,
|
||||
progress: json['progress'] as double? ?? 0.0,
|
||||
isCompleted: json['isCompleted'] as bool? ?? false,
|
||||
maxAttempts: json['maxAttempts'] as int? ?? 3,
|
||||
currentAttempts: json['currentAttempts'] as int? ?? 0,
|
||||
completionMessage: json['completionMessage'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'type': type.toString().split('.').last,
|
||||
'requirements': requirements,
|
||||
'progress': progress,
|
||||
'isCompleted': isCompleted,
|
||||
'maxAttempts': maxAttempts,
|
||||
'currentAttempts': currentAttempts,
|
||||
'completionMessage': completionMessage,
|
||||
};
|
||||
}
|
||||
|
||||
DialogueTask copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
DialogueTaskType? type,
|
||||
Map<String, dynamic>? requirements,
|
||||
double? progress,
|
||||
bool? isCompleted,
|
||||
int? maxAttempts,
|
||||
int? currentAttempts,
|
||||
String? completionMessage,
|
||||
}) {
|
||||
return DialogueTask(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
type: type ?? this.type,
|
||||
requirements: requirements ?? this.requirements,
|
||||
progress: progress ?? this.progress,
|
||||
isCompleted: isCompleted ?? this.isCompleted,
|
||||
maxAttempts: maxAttempts ?? this.maxAttempts,
|
||||
currentAttempts: currentAttempts ?? this.currentAttempts,
|
||||
completionMessage: completionMessage ?? this.completionMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 對話任務類型
|
||||
enum DialogueTaskType {
|
||||
conversation, // 完成對話
|
||||
vocabulary, // 使用指定詞彙
|
||||
grammar, // 語法練習
|
||||
pronunciation, // 發音練習
|
||||
comprehension, // 理解測試
|
||||
}
|
||||
|
||||
/// 對話分析結果模型
|
||||
class DialogueAnalysis {
|
||||
final String id;
|
||||
final String userReply;
|
||||
final DateTime timestamp;
|
||||
|
||||
// 三維度評分
|
||||
final double grammarScore;
|
||||
final double semanticsScore;
|
||||
final double fluencyScore;
|
||||
|
||||
// 詳細分析
|
||||
final List<GrammarIssue> grammarIssues;
|
||||
final List<String> usedVocabulary;
|
||||
final List<String> missedVocabulary;
|
||||
final List<String> suggestions;
|
||||
|
||||
// 任務相關
|
||||
final double? taskProgress;
|
||||
final bool isDialogueComplete;
|
||||
|
||||
// 其他
|
||||
final Map<String, dynamic> metadata;
|
||||
|
||||
DialogueAnalysis({
|
||||
required this.id,
|
||||
required this.userReply,
|
||||
required this.timestamp,
|
||||
required this.grammarScore,
|
||||
required this.semanticsScore,
|
||||
required this.fluencyScore,
|
||||
this.grammarIssues = const [],
|
||||
this.usedVocabulary = const [],
|
||||
this.missedVocabulary = const [],
|
||||
this.suggestions = const [],
|
||||
this.taskProgress,
|
||||
this.isDialogueComplete = false,
|
||||
this.metadata = const {},
|
||||
});
|
||||
|
||||
factory DialogueAnalysis.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueAnalysis(
|
||||
id: json['id'] as String,
|
||||
userReply: json['userReply'] as String,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
grammarScore: json['grammarScore'] as double,
|
||||
semanticsScore: json['semanticsScore'] as double,
|
||||
fluencyScore: json['fluencyScore'] as double,
|
||||
grammarIssues: (json['grammarIssues'] as List?)
|
||||
?.map((e) => GrammarIssue.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
usedVocabulary: List<String>.from(json['usedVocabulary'] ?? []),
|
||||
missedVocabulary: List<String>.from(json['missedVocabulary'] ?? []),
|
||||
suggestions: List<String>.from(json['suggestions'] ?? []),
|
||||
taskProgress: json['taskProgress'] as double?,
|
||||
isDialogueComplete: json['isDialogueComplete'] as bool? ?? false,
|
||||
metadata: json['metadata'] as Map<String, dynamic>? ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'userReply': userReply,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'grammarScore': grammarScore,
|
||||
'semanticsScore': semanticsScore,
|
||||
'fluencyScore': fluencyScore,
|
||||
'grammarIssues': grammarIssues.map((e) => e.toJson()).toList(),
|
||||
'usedVocabulary': usedVocabulary,
|
||||
'missedVocabulary': missedVocabulary,
|
||||
'suggestions': suggestions,
|
||||
'taskProgress': taskProgress,
|
||||
'isDialogueComplete': isDialogueComplete,
|
||||
'metadata': metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 語法問題模型
|
||||
class GrammarIssue {
|
||||
final String type;
|
||||
final String description;
|
||||
final String originalText;
|
||||
final String suggestedText;
|
||||
final int position;
|
||||
final int length;
|
||||
final GrammarIssueSeverity severity;
|
||||
|
||||
GrammarIssue({
|
||||
required this.type,
|
||||
required this.description,
|
||||
required this.originalText,
|
||||
required this.suggestedText,
|
||||
required this.position,
|
||||
required this.length,
|
||||
required this.severity,
|
||||
});
|
||||
|
||||
factory GrammarIssue.fromJson(Map<String, dynamic> json) {
|
||||
return GrammarIssue(
|
||||
type: json['type'] as String,
|
||||
description: json['description'] as String,
|
||||
originalText: json['originalText'] as String,
|
||||
suggestedText: json['suggestedText'] as String,
|
||||
position: json['position'] as int,
|
||||
length: json['length'] as int,
|
||||
severity: GrammarIssueSeverity.values.firstWhere(
|
||||
(e) => e.toString() == 'GrammarIssueSeverity.${json['severity']}',
|
||||
orElse: () => GrammarIssueSeverity.minor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type,
|
||||
'description': description,
|
||||
'originalText': originalText,
|
||||
'suggestedText': suggestedText,
|
||||
'position': position,
|
||||
'length': length,
|
||||
'severity': severity.toString().split('.').last,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 語法問題嚴重程度
|
||||
enum GrammarIssueSeverity {
|
||||
minor,
|
||||
moderate,
|
||||
major,
|
||||
critical,
|
||||
}
|
||||
|
||||
/// 對話最終得分模型
|
||||
class DialogueScore {
|
||||
final double grammarScore;
|
||||
final double semanticsScore;
|
||||
final double fluencyScore;
|
||||
final double taskBonus;
|
||||
final double vocabularyBonus;
|
||||
final double timeBonus;
|
||||
final double totalScore;
|
||||
final int starRating;
|
||||
final DateTime timestamp;
|
||||
final Map<String, dynamic> breakdown;
|
||||
|
||||
DialogueScore({
|
||||
required this.grammarScore,
|
||||
required this.semanticsScore,
|
||||
required this.fluencyScore,
|
||||
required this.taskBonus,
|
||||
required this.vocabularyBonus,
|
||||
required this.timeBonus,
|
||||
required this.totalScore,
|
||||
required this.starRating,
|
||||
DateTime? timestamp,
|
||||
this.breakdown = const {},
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
|
||||
factory DialogueScore.fromJson(Map<String, dynamic> json) {
|
||||
return DialogueScore(
|
||||
grammarScore: json['grammarScore'] as double,
|
||||
semanticsScore: json['semanticsScore'] as double,
|
||||
fluencyScore: json['fluencyScore'] as double,
|
||||
taskBonus: json['taskBonus'] as double,
|
||||
vocabularyBonus: json['vocabularyBonus'] as double,
|
||||
timeBonus: json['timeBonus'] as double,
|
||||
totalScore: json['totalScore'] as double,
|
||||
starRating: json['starRating'] as int,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
breakdown: json['breakdown'] as Map<String, dynamic>? ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'grammarScore': grammarScore,
|
||||
'semanticsScore': semanticsScore,
|
||||
'fluencyScore': fluencyScore,
|
||||
'taskBonus': taskBonus,
|
||||
'vocabularyBonus': vocabularyBonus,
|
||||
'timeBonus': timeBonus,
|
||||
'totalScore': totalScore,
|
||||
'starRating': starRating,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'breakdown': breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
String get grade {
|
||||
if (totalScore >= 90) return 'A+';
|
||||
if (totalScore >= 80) return 'A';
|
||||
if (totalScore >= 70) return 'B';
|
||||
if (totalScore >= 60) return 'C';
|
||||
if (totalScore >= 50) return 'D';
|
||||
return 'F';
|
||||
}
|
||||
|
||||
String get comment {
|
||||
switch (starRating) {
|
||||
case 3:
|
||||
return '優秀!你的表現非常出色!';
|
||||
case 2:
|
||||
return '很好!繼續努力就能更進一步!';
|
||||
case 1:
|
||||
return '不錯!還有改進的空間。';
|
||||
default:
|
||||
return '需要更多練習,加油!';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 詞彙項目模型
|
||||
class VocabularyItem {
|
||||
final String id;
|
||||
final String word;
|
||||
final String definition;
|
||||
final String pronunciation;
|
||||
final List<String> examples;
|
||||
final String category;
|
||||
final int difficulty;
|
||||
final bool isRequired;
|
||||
final bool isUsed;
|
||||
|
||||
VocabularyItem({
|
||||
required this.id,
|
||||
required this.word,
|
||||
required this.definition,
|
||||
required this.pronunciation,
|
||||
this.examples = const [],
|
||||
this.category = '',
|
||||
this.difficulty = 1,
|
||||
this.isRequired = false,
|
||||
this.isUsed = false,
|
||||
});
|
||||
|
||||
factory VocabularyItem.fromJson(Map<String, dynamic> json) {
|
||||
return VocabularyItem(
|
||||
id: json['id'] as String,
|
||||
word: json['word'] as String,
|
||||
definition: json['definition'] as String,
|
||||
pronunciation: json['pronunciation'] as String,
|
||||
examples: List<String>.from(json['examples'] ?? []),
|
||||
category: json['category'] as String? ?? '',
|
||||
difficulty: json['difficulty'] as int? ?? 1,
|
||||
isRequired: json['isRequired'] as bool? ?? false,
|
||||
isUsed: json['isUsed'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'word': word,
|
||||
'definition': definition,
|
||||
'pronunciation': pronunciation,
|
||||
'examples': examples,
|
||||
'category': category,
|
||||
'difficulty': difficulty,
|
||||
'isRequired': isRequired,
|
||||
'isUsed': isUsed,
|
||||
};
|
||||
}
|
||||
|
||||
VocabularyItem copyWith({
|
||||
String? id,
|
||||
String? word,
|
||||
String? definition,
|
||||
String? pronunciation,
|
||||
List<String>? examples,
|
||||
String? category,
|
||||
int? difficulty,
|
||||
bool? isRequired,
|
||||
bool? isUsed,
|
||||
}) {
|
||||
return VocabularyItem(
|
||||
id: id ?? this.id,
|
||||
word: word ?? this.word,
|
||||
definition: definition ?? this.definition,
|
||||
pronunciation: pronunciation ?? this.pronunciation,
|
||||
examples: examples ?? this.examples,
|
||||
category: category ?? this.category,
|
||||
difficulty: difficulty ?? this.difficulty,
|
||||
isRequired: isRequired ?? this.isRequired,
|
||||
isUsed: isUsed ?? this.isUsed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/dialogue_models.dart';
|
||||
import '../services/dialogue_service.dart';
|
||||
|
||||
/// 對話狀態提供者
|
||||
final dialogueProvider = StateNotifierProvider<DialogueNotifier, DialogueState>((ref) {
|
||||
final dialogueService = ref.watch(dialogueServiceProvider);
|
||||
return DialogueNotifier(dialogueService);
|
||||
});
|
||||
|
||||
/// 對話服務提供者
|
||||
final dialogueServiceProvider = Provider<DialogueService>((ref) {
|
||||
return DialogueService();
|
||||
});
|
||||
|
||||
/// 對話狀態管理
|
||||
class DialogueNotifier extends StateNotifier<DialogueState> {
|
||||
final DialogueService _dialogueService;
|
||||
|
||||
DialogueNotifier(this._dialogueService) : super(DialogueState.initial());
|
||||
|
||||
/// 初始化對話
|
||||
Future<void> initializeDialogue({
|
||||
required String scenarioId,
|
||||
required String levelId,
|
||||
bool isTimeChallenge = false,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
try {
|
||||
final scene = await _dialogueService.loadScene(scenarioId, levelId);
|
||||
final character = await _dialogueService.loadCharacter(scene.characterId);
|
||||
final task = await _dialogueService.loadTask(levelId);
|
||||
final vocabulary = await _dialogueService.loadRequiredVocabulary(levelId);
|
||||
|
||||
// 載入開場對話
|
||||
final openingDialogue = await _dialogueService.getOpeningDialogue(
|
||||
scenarioId,
|
||||
levelId,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
currentScene: scene,
|
||||
currentCharacter: character,
|
||||
currentTask: task,
|
||||
requiredVocabulary: vocabulary,
|
||||
currentDialogue: openingDialogue,
|
||||
scenarioId: scenarioId,
|
||||
levelId: levelId,
|
||||
isTimeChallenge: isTimeChallenge,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 發送用戶回覆
|
||||
Future<void> sendReply(String replyText) async {
|
||||
if (replyText.trim().isEmpty) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true);
|
||||
|
||||
try {
|
||||
// 創建用戶回覆
|
||||
final userReply = DialogueMessage(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
content: replyText,
|
||||
isUser: true,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
// 分析回覆
|
||||
final analysis = await _dialogueService.analyzeReply(
|
||||
scenarioId: state.scenarioId!,
|
||||
levelId: state.levelId!,
|
||||
replyText: replyText,
|
||||
requiredVocabulary: state.requiredVocabulary,
|
||||
currentTask: state.currentTask,
|
||||
);
|
||||
|
||||
// 獲取AI回應
|
||||
final aiResponse = await _dialogueService.getAIResponse(
|
||||
scenarioId: state.scenarioId!,
|
||||
levelId: state.levelId!,
|
||||
userReply: replyText,
|
||||
analysis: analysis,
|
||||
);
|
||||
|
||||
// 更新使用的詞彙
|
||||
final newUsedVocabulary = Set<String>.from(state.usedVocabulary);
|
||||
newUsedVocabulary.addAll(analysis.usedVocabulary);
|
||||
|
||||
// 更新任務進度
|
||||
DialogueTask? updatedTask = state.currentTask;
|
||||
if (analysis.taskProgress != null && updatedTask != null) {
|
||||
updatedTask = updatedTask.copyWith(
|
||||
progress: analysis.taskProgress!,
|
||||
isCompleted: analysis.taskProgress! >= 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
lastUserReply: userReply,
|
||||
currentDialogue: aiResponse,
|
||||
currentTask: updatedTask,
|
||||
usedVocabulary: newUsedVocabulary,
|
||||
lastAnalysis: analysis,
|
||||
conversationHistory: [...state.conversationHistory, userReply, aiResponse],
|
||||
);
|
||||
|
||||
// 檢查對話是否完成
|
||||
if (analysis.isDialogueComplete || (updatedTask?.isCompleted ?? false)) {
|
||||
_completeDialogue();
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 顯示回覆輔助
|
||||
Future<void> showReplyAssistance() async {
|
||||
if (state.diamonds < 30) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true);
|
||||
|
||||
try {
|
||||
final suggestions = await _dialogueService.getReplyAssistance(
|
||||
scenarioId: state.scenarioId!,
|
||||
levelId: state.levelId!,
|
||||
currentDialogue: state.currentDialogue?.content ?? '',
|
||||
currentTask: state.currentTask,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
showReplyAssistance: true,
|
||||
replySuggestions: suggestions,
|
||||
diamonds: state.diamonds - 30, // 扣除鑽石
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isProcessing: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 隱藏回覆輔助
|
||||
void hideReplyAssistance() {
|
||||
state = state.copyWith(
|
||||
showReplyAssistance: false,
|
||||
replySuggestions: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// 使用時光卷道具
|
||||
Future<void> useTimeWarpCard() async {
|
||||
// TODO: 實現時光卷功能
|
||||
}
|
||||
|
||||
/// 完成對話
|
||||
void _completeDialogue() {
|
||||
final finalScore = _calculateFinalScore();
|
||||
|
||||
state = state.copyWith(
|
||||
isCompleted: true,
|
||||
finalScore: finalScore,
|
||||
);
|
||||
}
|
||||
|
||||
/// 計算最終得分
|
||||
DialogueScore _calculateFinalScore() {
|
||||
// 計算三維度得分
|
||||
double grammarScore = 0.0;
|
||||
double semanticsScore = 0.0;
|
||||
double fluencyScore = 0.0;
|
||||
int totalReplies = 0;
|
||||
|
||||
for (final analysis in state.analysisHistory) {
|
||||
grammarScore += analysis.grammarScore;
|
||||
semanticsScore += analysis.semanticsScore;
|
||||
fluencyScore += analysis.fluencyScore;
|
||||
totalReplies++;
|
||||
}
|
||||
|
||||
if (totalReplies > 0) {
|
||||
grammarScore /= totalReplies;
|
||||
semanticsScore /= totalReplies;
|
||||
fluencyScore /= totalReplies;
|
||||
}
|
||||
|
||||
// 計算任務完成度獎勵
|
||||
double taskBonus = 0.0;
|
||||
if (state.currentTask?.isCompleted ?? false) {
|
||||
taskBonus = 20.0;
|
||||
}
|
||||
|
||||
// 計算詞彙使用獎勵
|
||||
double vocabularyBonus = 0.0;
|
||||
if (state.requiredVocabulary.isNotEmpty) {
|
||||
vocabularyBonus = (state.usedVocabulary.length / state.requiredVocabulary.length) * 10.0;
|
||||
}
|
||||
|
||||
// 計算時間獎勵(限時挑戰)
|
||||
double timeBonus = 0.0;
|
||||
if (state.isTimeChallenge) {
|
||||
// TODO: 根據剩餘時間計算獎勵
|
||||
timeBonus = 5.0;
|
||||
}
|
||||
|
||||
final totalScore = grammarScore + semanticsScore + fluencyScore + taskBonus + vocabularyBonus + timeBonus;
|
||||
|
||||
return DialogueScore(
|
||||
grammarScore: grammarScore,
|
||||
semanticsScore: semanticsScore,
|
||||
fluencyScore: fluencyScore,
|
||||
taskBonus: taskBonus,
|
||||
vocabularyBonus: vocabularyBonus,
|
||||
timeBonus: timeBonus,
|
||||
totalScore: totalScore,
|
||||
starRating: _calculateStarRating(totalScore),
|
||||
);
|
||||
}
|
||||
|
||||
/// 計算星級評價
|
||||
int _calculateStarRating(double totalScore) {
|
||||
if (totalScore >= 90) return 3;
|
||||
if (totalScore >= 70) return 2;
|
||||
if (totalScore >= 50) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 重置對話狀態
|
||||
void reset() {
|
||||
state = DialogueState.initial();
|
||||
}
|
||||
}
|
||||
|
||||
/// 對話狀態
|
||||
class DialogueState {
|
||||
final bool isLoading;
|
||||
final bool isProcessing;
|
||||
final bool isCompleted;
|
||||
final String? error;
|
||||
|
||||
// 場景信息
|
||||
final String? scenarioId;
|
||||
final String? levelId;
|
||||
final bool isTimeChallenge;
|
||||
final DialogueScene? currentScene;
|
||||
final DialogueCharacter? currentCharacter;
|
||||
|
||||
// 對話內容
|
||||
final DialogueMessage? currentDialogue;
|
||||
final DialogueMessage? lastUserReply;
|
||||
final List<DialogueMessage> conversationHistory;
|
||||
|
||||
// 任務和詞彙
|
||||
final DialogueTask? currentTask;
|
||||
final List<String> requiredVocabulary;
|
||||
final Set<String> usedVocabulary;
|
||||
|
||||
// AI分析
|
||||
final DialogueAnalysis? lastAnalysis;
|
||||
final List<DialogueAnalysis> analysisHistory;
|
||||
|
||||
// 回覆輔助
|
||||
final bool showReplyAssistance;
|
||||
final List<String> replySuggestions;
|
||||
|
||||
// 資源
|
||||
final int lifePoints;
|
||||
final int diamonds;
|
||||
|
||||
// 設置
|
||||
final String currentLanguage;
|
||||
|
||||
// 最終結果
|
||||
final DialogueScore? finalScore;
|
||||
|
||||
DialogueState({
|
||||
required this.isLoading,
|
||||
required this.isProcessing,
|
||||
required this.isCompleted,
|
||||
this.error,
|
||||
this.scenarioId,
|
||||
this.levelId,
|
||||
required this.isTimeChallenge,
|
||||
this.currentScene,
|
||||
this.currentCharacter,
|
||||
this.currentDialogue,
|
||||
this.lastUserReply,
|
||||
required this.conversationHistory,
|
||||
this.currentTask,
|
||||
required this.requiredVocabulary,
|
||||
required this.usedVocabulary,
|
||||
this.lastAnalysis,
|
||||
required this.analysisHistory,
|
||||
required this.showReplyAssistance,
|
||||
required this.replySuggestions,
|
||||
required this.lifePoints,
|
||||
required this.diamonds,
|
||||
required this.currentLanguage,
|
||||
this.finalScore,
|
||||
});
|
||||
|
||||
factory DialogueState.initial() {
|
||||
return DialogueState(
|
||||
isLoading: false,
|
||||
isProcessing: false,
|
||||
isCompleted: false,
|
||||
isTimeChallenge: false,
|
||||
conversationHistory: [],
|
||||
requiredVocabulary: [],
|
||||
usedVocabulary: {},
|
||||
analysisHistory: [],
|
||||
showReplyAssistance: false,
|
||||
replySuggestions: [],
|
||||
lifePoints: 5,
|
||||
diamonds: 100,
|
||||
currentLanguage: 'zh-TW',
|
||||
);
|
||||
}
|
||||
|
||||
DialogueState copyWith({
|
||||
bool? isLoading,
|
||||
bool? isProcessing,
|
||||
bool? isCompleted,
|
||||
String? error,
|
||||
String? scenarioId,
|
||||
String? levelId,
|
||||
bool? isTimeChallenge,
|
||||
DialogueScene? currentScene,
|
||||
DialogueCharacter? currentCharacter,
|
||||
DialogueMessage? currentDialogue,
|
||||
DialogueMessage? lastUserReply,
|
||||
List<DialogueMessage>? conversationHistory,
|
||||
DialogueTask? currentTask,
|
||||
List<String>? requiredVocabulary,
|
||||
Set<String>? usedVocabulary,
|
||||
DialogueAnalysis? lastAnalysis,
|
||||
List<DialogueAnalysis>? analysisHistory,
|
||||
bool? showReplyAssistance,
|
||||
List<String>? replySuggestions,
|
||||
int? lifePoints,
|
||||
int? diamonds,
|
||||
String? currentLanguage,
|
||||
DialogueScore? finalScore,
|
||||
}) {
|
||||
return DialogueState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
isCompleted: isCompleted ?? this.isCompleted,
|
||||
error: error ?? this.error,
|
||||
scenarioId: scenarioId ?? this.scenarioId,
|
||||
levelId: levelId ?? this.levelId,
|
||||
isTimeChallenge: isTimeChallenge ?? this.isTimeChallenge,
|
||||
currentScene: currentScene ?? this.currentScene,
|
||||
currentCharacter: currentCharacter ?? this.currentCharacter,
|
||||
currentDialogue: currentDialogue ?? this.currentDialogue,
|
||||
lastUserReply: lastUserReply ?? this.lastUserReply,
|
||||
conversationHistory: conversationHistory ?? this.conversationHistory,
|
||||
currentTask: currentTask ?? this.currentTask,
|
||||
requiredVocabulary: requiredVocabulary ?? this.requiredVocabulary,
|
||||
usedVocabulary: usedVocabulary ?? this.usedVocabulary,
|
||||
lastAnalysis: lastAnalysis ?? this.lastAnalysis,
|
||||
analysisHistory: analysisHistory ?? List.from(this.analysisHistory),
|
||||
showReplyAssistance: showReplyAssistance ?? this.showReplyAssistance,
|
||||
replySuggestions: replySuggestions ?? this.replySuggestions,
|
||||
lifePoints: lifePoints ?? this.lifePoints,
|
||||
diamonds: diamonds ?? this.diamonds,
|
||||
currentLanguage: currentLanguage ?? this.currentLanguage,
|
||||
finalScore: finalScore ?? this.finalScore,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DialogueState(loading: $isLoading, processing: $isProcessing, completed: $isCompleted, scenarioId: $scenarioId, levelId: $levelId)';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,656 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../../../shared/widgets/voice_input_button.dart';
|
||||
import '../providers/dialogue_provider.dart';
|
||||
import '../widgets/dialogue_background.dart';
|
||||
import '../widgets/character_avatar.dart';
|
||||
import '../widgets/dialogue_bubble.dart';
|
||||
import '../widgets/task_display_panel.dart';
|
||||
import '../widgets/vocabulary_panel.dart';
|
||||
import '../widgets/reply_assistance_panel.dart';
|
||||
|
||||
/// 情境對話主界面
|
||||
///
|
||||
/// 實現完整的AI對話功能,包括:
|
||||
/// - 沉浸式場景背景
|
||||
/// - 角色對話展示
|
||||
/// - 語音和文字輸入
|
||||
/// - 任務進度追蹤
|
||||
/// - 指定詞彙提示
|
||||
/// - 回覆輔助功能
|
||||
/// - 限時挑戰模式
|
||||
class DialogueMainScreen extends ConsumerStatefulWidget {
|
||||
/// 場景ID
|
||||
final String scenarioId;
|
||||
|
||||
/// 關卡ID
|
||||
final String levelId;
|
||||
|
||||
/// 是否為限時挑戰模式
|
||||
final bool isTimeChallenge;
|
||||
|
||||
const DialogueMainScreen({
|
||||
super.key,
|
||||
required this.scenarioId,
|
||||
required this.levelId,
|
||||
this.isTimeChallenge = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DialogueMainScreen> createState() => _DialogueMainScreenState();
|
||||
}
|
||||
|
||||
class _DialogueMainScreenState extends ConsumerState<DialogueMainScreen>
|
||||
with TickerProviderStateMixin {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _textFocusNode = FocusNode();
|
||||
late AnimationController _timerController;
|
||||
late Animation<double> _timerAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 限時挑戰計時器動畫
|
||||
_timerController = AnimationController(
|
||||
duration: const Duration(seconds: 300), // 300秒 = 5分鐘
|
||||
vsync: this,
|
||||
);
|
||||
_timerAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _timerController,
|
||||
curve: Curves.linear,
|
||||
));
|
||||
|
||||
// 如果是限時挑戰,開始計時
|
||||
if (widget.isTimeChallenge) {
|
||||
_timerController.forward();
|
||||
_timerController.addStatusListener(_onTimerComplete);
|
||||
}
|
||||
|
||||
// 初始化對話
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(dialogueProvider.notifier).initializeDialogue(
|
||||
scenarioId: widget.scenarioId,
|
||||
levelId: widget.levelId,
|
||||
isTimeChallenge: widget.isTimeChallenge,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textController.dispose();
|
||||
_textFocusNode.dispose();
|
||||
_timerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 計時器完成處理
|
||||
void _onTimerComplete(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
_showTimeUpDialog();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dialogueState = ref.watch(dialogueProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// 背景場景
|
||||
DialogueBackground(
|
||||
scenarioId: widget.scenarioId,
|
||||
backgroundUrl: dialogueState.currentScene?.backgroundImageUrl,
|
||||
),
|
||||
|
||||
// 主要內容
|
||||
Column(
|
||||
children: [
|
||||
// 頂部工具列
|
||||
_buildTopBar(dialogueState),
|
||||
|
||||
// 對話內容區域
|
||||
Expanded(
|
||||
child: _buildDialogueContent(dialogueState),
|
||||
),
|
||||
|
||||
// 底部輸入區域
|
||||
_buildInputArea(dialogueState),
|
||||
],
|
||||
),
|
||||
|
||||
// 任務顯示面板
|
||||
if (dialogueState.currentTask != null)
|
||||
Positioned(
|
||||
top: 80.h,
|
||||
right: 16.w,
|
||||
child: TaskDisplayPanel(
|
||||
task: dialogueState.currentTask!,
|
||||
),
|
||||
),
|
||||
|
||||
// 指定詞彙面板
|
||||
if (dialogueState.requiredVocabulary.isNotEmpty)
|
||||
Positioned(
|
||||
top: 80.h,
|
||||
left: 16.w,
|
||||
child: VocabularyPanel(
|
||||
vocabularies: dialogueState.requiredVocabulary,
|
||||
usedVocabularies: dialogueState.usedVocabulary,
|
||||
),
|
||||
),
|
||||
|
||||
// 回覆輔助面板
|
||||
if (dialogueState.showReplyAssistance)
|
||||
Positioned.fill(
|
||||
child: ReplyAssistancePanel(
|
||||
suggestions: dialogueState.replySuggestions,
|
||||
onSelectSuggestion: _onSelectSuggestion,
|
||||
onClose: _closeReplyAssistance,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 頂部工具列
|
||||
Widget _buildTopBar(DialogueState state) {
|
||||
return Container(
|
||||
height: 60.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.8),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按鈕
|
||||
IconButton(
|
||||
onPressed: _showExitConfirmation,
|
||||
icon: Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Colors.white,
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 限時挑戰計時器
|
||||
if (widget.isTimeChallenge)
|
||||
AnimatedBuilder(
|
||||
animation: _timerAnimation,
|
||||
builder: (context, child) {
|
||||
final remainingSeconds = (_timerAnimation.value * 300).round();
|
||||
final minutes = remainingSeconds ~/ 60;
|
||||
final seconds = remainingSeconds % 60;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12.w,
|
||||
vertical: 6.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: remainingSeconds < 60
|
||||
? Colors.red.withOpacity(0.8)
|
||||
: Colors.black.withOpacity(0.6),
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.timer,
|
||||
color: Colors.white,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Text(
|
||||
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 資源顯示
|
||||
Row(
|
||||
children: [
|
||||
// 命條
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.favorite,
|
||||
color: Colors.red,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Text(
|
||||
'${state.lifePoints}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(width: 16.w),
|
||||
|
||||
// 鑽石
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.diamond,
|
||||
color: Colors.blue,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
Text(
|
||||
'${state.diamonds}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 對話內容區域
|
||||
Widget _buildDialogueContent(DialogueState state) {
|
||||
return Column(
|
||||
children: [
|
||||
// 角色頭像和名稱
|
||||
if (state.currentCharacter != null)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h),
|
||||
child: CharacterAvatar(
|
||||
character: state.currentCharacter!,
|
||||
showDetails: true,
|
||||
),
|
||||
),
|
||||
|
||||
// 對話氣泡
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: state.currentDialogue != null
|
||||
? DialogueBubble(
|
||||
dialogue: state.currentDialogue!,
|
||||
isUserReply: false,
|
||||
)
|
||||
: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 用戶回覆氣泡(如果有的話)
|
||||
if (state.lastUserReply != null)
|
||||
Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 20.w, vertical: 8.h),
|
||||
child: DialogueBubble(
|
||||
dialogue: state.lastUserReply!,
|
||||
isUserReply: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 底部輸入區域
|
||||
Widget _buildInputArea(DialogueState state) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.9),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 功能按鈕行
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// 角色詳情
|
||||
_buildFunctionButton(
|
||||
icon: Icons.person,
|
||||
label: '角色',
|
||||
onTap: _showCharacterDetails,
|
||||
),
|
||||
|
||||
// 關鍵詞
|
||||
_buildFunctionButton(
|
||||
icon: Icons.key,
|
||||
label: '關鍵詞',
|
||||
onTap: _showKeywords,
|
||||
),
|
||||
|
||||
// 任務提示
|
||||
_buildFunctionButton(
|
||||
icon: Icons.lightbulb,
|
||||
label: '任務',
|
||||
onTap: _showTaskHint,
|
||||
disabled: state.currentTask?.isCompleted ?? true,
|
||||
),
|
||||
|
||||
// 中翻英
|
||||
_buildFunctionButton(
|
||||
icon: Icons.translate,
|
||||
label: '翻譯',
|
||||
onTap: _showTranslation,
|
||||
),
|
||||
|
||||
// 回覆輔助
|
||||
_buildFunctionButton(
|
||||
icon: Icons.help,
|
||||
label: '輔助',
|
||||
onTap: _showReplyAssistance,
|
||||
cost: 30,
|
||||
disabled: state.diamonds < 30,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
// 輸入區域
|
||||
Row(
|
||||
children: [
|
||||
// 文字輸入框
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFocusNode,
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '請輸入你的回覆...',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 16.sp,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.black.withOpacity(0.6),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24.r),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 12.h,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(width: 8.w),
|
||||
|
||||
// 語音輸入按鈕
|
||||
VoiceInputButton(
|
||||
size: 48,
|
||||
languageId: state.currentLanguage,
|
||||
onResult: _onVoiceResult,
|
||||
onError: _onVoiceError,
|
||||
),
|
||||
|
||||
SizedBox(width: 8.w),
|
||||
|
||||
// 發送按鈕
|
||||
Container(
|
||||
width: 48.w,
|
||||
height: 48.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _textController.text.trim().isNotEmpty
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.grey,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: _textController.text.trim().isNotEmpty
|
||||
? _sendReply
|
||||
: null,
|
||||
icon: Icon(
|
||||
Icons.send,
|
||||
color: Colors.white,
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 功能按鈕
|
||||
Widget _buildFunctionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
int? cost,
|
||||
bool disabled = false,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: disabled ? null : onTap,
|
||||
child: Container(
|
||||
width: 60.w,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40.w,
|
||||
height: 40.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: disabled
|
||||
? Colors.grey.withOpacity(0.3)
|
||||
: Colors.white.withOpacity(0.2),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Icon(
|
||||
icon,
|
||||
color: disabled ? Colors.grey : Colors.white,
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
if (cost != null)
|
||||
Positioned(
|
||||
top: -2.h,
|
||||
right: -2.w,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(2.w),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.blue,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.diamond,
|
||||
color: Colors.white,
|
||||
size: 8.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
cost != null ? '$label($cost)' : label,
|
||||
style: TextStyle(
|
||||
color: disabled ? Colors.grey : Colors.white,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 語音識別結果處理
|
||||
void _onVoiceResult(String text) {
|
||||
setState(() {
|
||||
_textController.text = text;
|
||||
});
|
||||
}
|
||||
|
||||
/// 語音識別錯誤處理
|
||||
void _onVoiceError(String error) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('語音識別失敗:$error'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 發送回覆
|
||||
void _sendReply() {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
ref.read(dialogueProvider.notifier).sendReply(text);
|
||||
_textController.clear();
|
||||
}
|
||||
|
||||
/// 選擇建議回覆
|
||||
void _onSelectSuggestion(String suggestion) {
|
||||
setState(() {
|
||||
_textController.text = suggestion;
|
||||
});
|
||||
_closeReplyAssistance();
|
||||
}
|
||||
|
||||
/// 顯示角色詳情
|
||||
void _showCharacterDetails() {
|
||||
// TODO: 導航到角色詳情頁面
|
||||
}
|
||||
|
||||
/// 顯示關鍵詞
|
||||
void _showKeywords() {
|
||||
// TODO: 導航到關鍵詞頁面
|
||||
}
|
||||
|
||||
/// 顯示任務提示
|
||||
void _showTaskHint() {
|
||||
// TODO: 顯示任務提示對話框
|
||||
}
|
||||
|
||||
/// 顯示翻譯
|
||||
void _showTranslation() {
|
||||
// TODO: 顯示翻譯對話框
|
||||
}
|
||||
|
||||
/// 顯示回覆輔助
|
||||
void _showReplyAssistance() {
|
||||
ref.read(dialogueProvider.notifier).showReplyAssistance();
|
||||
}
|
||||
|
||||
/// 關閉回覆輔助
|
||||
void _closeReplyAssistance() {
|
||||
ref.read(dialogueProvider.notifier).hideReplyAssistance();
|
||||
}
|
||||
|
||||
/// 顯示退出確認
|
||||
void _showExitConfirmation() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('確認離開'),
|
||||
content: Text(
|
||||
widget.isTimeChallenge
|
||||
? '離開限時挑戰將無法繼續,確定要離開嗎?'
|
||||
: '確定要離開對話嗎?當前進度將會保存。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('確定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 顯示時間結束對話框
|
||||
void _showTimeUpDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('時間到!'),
|
||||
content: Text('限時挑戰時間已結束,正在計算成績...'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// TODO: 跳轉到結果頁面
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('查看結果'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
import 'dart:async';
|
||||
|
||||
import '../models/dialogue_models.dart';
|
||||
|
||||
/// 對話服務
|
||||
///
|
||||
/// 提供完整的對話管理功能,包括:
|
||||
/// - 場景和角色加載
|
||||
/// - AI回應生成
|
||||
/// - 語言分析和評分
|
||||
/// - 任務進度跟踪
|
||||
class DialogueService {
|
||||
/// 加載場景信息
|
||||
Future<DialogueScene> loadScene(String scenarioId, String levelId) async {
|
||||
// 模擬API調用延遲
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// 返回模擬數據
|
||||
return DialogueScene(
|
||||
id: scenarioId,
|
||||
name: '餐廳用餐',
|
||||
description: '在餐廳與服務員進行日常對話',
|
||||
backgroundImageUrl: 'assets/images/restaurant_bg.jpg',
|
||||
characterId: 'waiter_001',
|
||||
difficultyLevel: 'beginner',
|
||||
tags: ['restaurant', 'ordering', 'daily'],
|
||||
);
|
||||
}
|
||||
|
||||
/// 加載角色信息
|
||||
Future<DialogueCharacter> loadCharacter(String characterId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
return DialogueCharacter(
|
||||
id: characterId,
|
||||
name: '小王',
|
||||
description: '友善的餐廳服務員',
|
||||
avatarUrl: 'assets/images/waiter_avatar.jpg',
|
||||
personality: '友善、耐心、專業',
|
||||
role: '服務員',
|
||||
background: '在餐廳工作了3年,非常熟悉菜單和服務流程',
|
||||
specialities: ['點餐服務', '菜品介紹', '客戶服務'],
|
||||
);
|
||||
}
|
||||
|
||||
/// 加載任務信息
|
||||
Future<DialogueTask> loadTask(String levelId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
return DialogueTask(
|
||||
id: 'task_$levelId',
|
||||
title: '完成點餐',
|
||||
description: '與服務員完成一次完整的點餐對話,包括詢問菜品、下訂單、確認價格',
|
||||
type: DialogueTaskType.conversation,
|
||||
requirements: {
|
||||
'minTurns': 5,
|
||||
'mustUseWords': ['menu', 'order', 'price'],
|
||||
'completionCriteria': ['greeting', 'ordering', 'confirmation'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 加載必需詞彙
|
||||
Future<List<String>> loadRequiredVocabulary(String levelId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
|
||||
return [
|
||||
'menu',
|
||||
'order',
|
||||
'price',
|
||||
'recommendation',
|
||||
'delicious',
|
||||
'bill',
|
||||
];
|
||||
}
|
||||
|
||||
/// 獲取開場對話
|
||||
Future<DialogueMessage> getOpeningDialogue(String scenarioId, String levelId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
return DialogueMessage(
|
||||
id: 'opening_${DateTime.now().millisecondsSinceEpoch}',
|
||||
content: '歡迎光臨!請問您需要什麼嗎?我可以為您介紹今天的特色菜。',
|
||||
isUser: false,
|
||||
timestamp: DateTime.now(),
|
||||
type: DialogueMessageType.text,
|
||||
);
|
||||
}
|
||||
|
||||
/// 分析用戶回覆
|
||||
Future<DialogueAnalysis> analyzeReply({
|
||||
required String scenarioId,
|
||||
required String levelId,
|
||||
required String replyText,
|
||||
required List<String> requiredVocabulary,
|
||||
DialogueTask? currentTask,
|
||||
}) async {
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
// 模擬AI分析
|
||||
final usedWords = _findUsedVocabulary(replyText, requiredVocabulary);
|
||||
final grammarIssues = _analyzeGrammar(replyText);
|
||||
|
||||
// 計算得分
|
||||
final grammarScore = _calculateGrammarScore(grammarIssues);
|
||||
final semanticsScore = _calculateSemanticsScore(replyText, currentTask);
|
||||
final fluencyScore = _calculateFluencyScore(replyText);
|
||||
|
||||
// 計算任務進度
|
||||
double? taskProgress;
|
||||
if (currentTask != null) {
|
||||
taskProgress = _calculateTaskProgress(replyText, currentTask, usedWords);
|
||||
}
|
||||
|
||||
return DialogueAnalysis(
|
||||
id: 'analysis_${DateTime.now().millisecondsSinceEpoch}',
|
||||
userReply: replyText,
|
||||
timestamp: DateTime.now(),
|
||||
grammarScore: grammarScore,
|
||||
semanticsScore: semanticsScore,
|
||||
fluencyScore: fluencyScore,
|
||||
grammarIssues: grammarIssues,
|
||||
usedVocabulary: usedWords,
|
||||
missedVocabulary: requiredVocabulary.where((word) => !usedWords.contains(word)).toList(),
|
||||
suggestions: _generateSuggestions(replyText, grammarIssues),
|
||||
taskProgress: taskProgress,
|
||||
isDialogueComplete: taskProgress != null && taskProgress >= 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 獲取AI回應
|
||||
Future<DialogueMessage> getAIResponse({
|
||||
required String scenarioId,
|
||||
required String levelId,
|
||||
required String userReply,
|
||||
required DialogueAnalysis analysis,
|
||||
}) async {
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
|
||||
// 根據用戶回覆生成AI回應
|
||||
String response = _generateAIResponse(userReply, analysis);
|
||||
|
||||
return DialogueMessage(
|
||||
id: 'ai_response_${DateTime.now().millisecondsSinceEpoch}',
|
||||
content: response,
|
||||
isUser: false,
|
||||
timestamp: DateTime.now(),
|
||||
type: DialogueMessageType.text,
|
||||
metadata: {
|
||||
'responseType': 'contextual',
|
||||
'grammarScore': analysis.grammarScore,
|
||||
'semanticsScore': analysis.semanticsScore,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 獲取回覆輔助建議
|
||||
Future<List<String>> getReplyAssistance({
|
||||
required String scenarioId,
|
||||
required String levelId,
|
||||
required String currentDialogue,
|
||||
DialogueTask? currentTask,
|
||||
}) async {
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
// 根據當前對話內容生成建議回覆
|
||||
return [
|
||||
'可以給我看一下菜單嗎?',
|
||||
'請推薦一些招牌菜。',
|
||||
'這個菜的價格是多少?',
|
||||
'我想要點這個。',
|
||||
'謝謝,我考慮一下。',
|
||||
];
|
||||
}
|
||||
|
||||
/// 查找使用的詞彙
|
||||
List<String> _findUsedVocabulary(String text, List<String> requiredVocabulary) {
|
||||
final usedWords = <String>[];
|
||||
final lowerText = text.toLowerCase();
|
||||
|
||||
for (final word in requiredVocabulary) {
|
||||
if (lowerText.contains(word.toLowerCase())) {
|
||||
usedWords.add(word);
|
||||
}
|
||||
}
|
||||
|
||||
return usedWords;
|
||||
}
|
||||
|
||||
/// 分析語法
|
||||
List<GrammarIssue> _analyzeGrammar(String text) {
|
||||
final issues = <GrammarIssue>[];
|
||||
|
||||
// 簡單的語法檢查模擬
|
||||
if (text.length < 5) {
|
||||
issues.add(GrammarIssue(
|
||||
type: 'length',
|
||||
description: '回覆太短,請提供更完整的句子',
|
||||
originalText: text,
|
||||
suggestedText: '$text(建議擴展內容)',
|
||||
position: 0,
|
||||
length: text.length,
|
||||
severity: GrammarIssueSeverity.minor,
|
||||
));
|
||||
}
|
||||
|
||||
if (!text.endsWith('.') && !text.endsWith('?') && !text.endsWith('!') && !text.endsWith('?')) {
|
||||
issues.add(GrammarIssue(
|
||||
type: 'punctuation',
|
||||
description: '建議在句尾加上標點符號',
|
||||
originalText: text,
|
||||
suggestedText: '$text。',
|
||||
position: text.length,
|
||||
length: 0,
|
||||
severity: GrammarIssueSeverity.minor,
|
||||
));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// 計算語法得分
|
||||
double _calculateGrammarScore(List<GrammarIssue> issues) {
|
||||
if (issues.isEmpty) return 95.0;
|
||||
|
||||
double penalty = 0.0;
|
||||
for (final issue in issues) {
|
||||
switch (issue.severity) {
|
||||
case GrammarIssueSeverity.critical:
|
||||
penalty += 20.0;
|
||||
break;
|
||||
case GrammarIssueSeverity.major:
|
||||
penalty += 15.0;
|
||||
break;
|
||||
case GrammarIssueSeverity.moderate:
|
||||
penalty += 10.0;
|
||||
break;
|
||||
case GrammarIssueSeverity.minor:
|
||||
penalty += 5.0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (100.0 - penalty).clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
/// 計算語意得分
|
||||
double _calculateSemanticsScore(String text, DialogueTask? task) {
|
||||
// 基礎語意得分
|
||||
double score = 75.0;
|
||||
|
||||
// 根據文字長度和內容豐富度調整
|
||||
if (text.length > 20) score += 10.0;
|
||||
if (text.length > 50) score += 5.0;
|
||||
|
||||
// 根據任務相關性調整
|
||||
if (task != null) {
|
||||
final requirements = task.requirements['mustUseWords'] as List<dynamic>?;
|
||||
if (requirements != null) {
|
||||
final requiredWords = requirements.cast<String>();
|
||||
final usedCount = requiredWords.where((word) => text.toLowerCase().contains(word.toLowerCase())).length;
|
||||
score += (usedCount / requiredWords.length) * 20.0;
|
||||
}
|
||||
}
|
||||
|
||||
return score.clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
/// 計算流暢度得分
|
||||
double _calculateFluencyScore(String text) {
|
||||
// 基礎流暢度得分
|
||||
double score = 80.0;
|
||||
|
||||
// 根據句子結構調整
|
||||
if (text.contains(',') || text.contains(',')) score += 5.0;
|
||||
if (text.split(' ').length > 5 || text.length > 15) score += 10.0;
|
||||
|
||||
// 檢查是否有重複詞語
|
||||
final words = text.split(RegExp(r'\s+'));
|
||||
final uniqueWords = words.toSet();
|
||||
if (words.length != uniqueWords.length) score -= 5.0;
|
||||
|
||||
return score.clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
/// 計算任務進度
|
||||
double _calculateTaskProgress(String text, DialogueTask task, List<String> usedWords) {
|
||||
double progress = 0.0;
|
||||
final requirements = task.requirements;
|
||||
|
||||
// 檢查必需詞彙
|
||||
final mustUseWords = requirements['mustUseWords'] as List<dynamic>?;
|
||||
if (mustUseWords != null) {
|
||||
final requiredWords = mustUseWords.cast<String>();
|
||||
final usedRequiredWords = requiredWords.where((word) => usedWords.contains(word)).length;
|
||||
progress += (usedRequiredWords / requiredWords.length) * 0.5;
|
||||
}
|
||||
|
||||
// 檢查完成標準
|
||||
final completionCriteria = requirements['completionCriteria'] as List<dynamic>?;
|
||||
if (completionCriteria != null) {
|
||||
final criteria = completionCriteria.cast<String>();
|
||||
int metCriteria = 0;
|
||||
|
||||
for (final criterion in criteria) {
|
||||
if (_checkCriterion(text, criterion)) {
|
||||
metCriteria++;
|
||||
}
|
||||
}
|
||||
|
||||
progress += (metCriteria / criteria.length) * 0.5;
|
||||
}
|
||||
|
||||
return progress.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// 檢查完成標準
|
||||
bool _checkCriterion(String text, String criterion) {
|
||||
final lowerText = text.toLowerCase();
|
||||
|
||||
switch (criterion) {
|
||||
case 'greeting':
|
||||
return lowerText.contains('hello') || lowerText.contains('hi') ||
|
||||
lowerText.contains('你好') || lowerText.contains('哈囉');
|
||||
case 'ordering':
|
||||
return lowerText.contains('order') || lowerText.contains('want') ||
|
||||
lowerText.contains('點') || lowerText.contains('要');
|
||||
case 'confirmation':
|
||||
return lowerText.contains('confirm') || lowerText.contains('yes') ||
|
||||
lowerText.contains('ok') || lowerText.contains('確認') ||
|
||||
lowerText.contains('好的');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成建議
|
||||
List<String> _generateSuggestions(String text, List<GrammarIssue> issues) {
|
||||
final suggestions = <String>[];
|
||||
|
||||
for (final issue in issues) {
|
||||
suggestions.add('${issue.description}: "${issue.suggestedText}"');
|
||||
}
|
||||
|
||||
if (text.length < 10) {
|
||||
suggestions.add('試著提供更詳細的回應');
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/// 生成AI回應
|
||||
String _generateAIResponse(String userReply, DialogueAnalysis analysis) {
|
||||
final lowerReply = userReply.toLowerCase();
|
||||
|
||||
// 根據用戶回覆內容生成相應回應
|
||||
if (lowerReply.contains('menu') || lowerReply.contains('菜單')) {
|
||||
return '好的,這是我們的菜單。我們今天的特色菜是紅燒肉和宮保雞丁,都很受歡迎呢!';
|
||||
} else if (lowerReply.contains('recommend') || lowerReply.contains('推薦')) {
|
||||
return '我推薦我們的招牌菜紅燒肉,還有今天新鮮的清蒸魚。您比較喜歡什麼口味的呢?';
|
||||
} else if (lowerReply.contains('price') || lowerReply.contains('多少錢') || lowerReply.contains('價格')) {
|
||||
return '紅燒肉是28元,清蒸魚是35元。這些都是我們的人氣菜品,分量也很足。';
|
||||
} else if (lowerReply.contains('order') || lowerReply.contains('點') || lowerReply.contains('要')) {
|
||||
return '好的,已經為您記下了。還需要什麼其他的嗎?飲料或者湯品?';
|
||||
} else if (lowerReply.contains('thank') || lowerReply.contains('謝謝')) {
|
||||
return '不客氣!如果還有什麼需要,請隨時告訴我。';
|
||||
} else {
|
||||
// 預設回應
|
||||
return '我明白了。還有什麼我可以為您服務的嗎?';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
import '../models/dialogue_models.dart';
|
||||
|
||||
/// 角色頭像組件
|
||||
class CharacterAvatar extends StatelessWidget {
|
||||
final DialogueCharacter character;
|
||||
final bool showDetails;
|
||||
final double size;
|
||||
|
||||
const CharacterAvatar({
|
||||
super.key,
|
||||
required this.character,
|
||||
this.showDetails = false,
|
||||
this.size = 80.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 頭像
|
||||
Container(
|
||||
width: size.w,
|
||||
height: size.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).primaryColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: character.avatarUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: size.w * 0.5,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[300],
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: size.w * 0.5,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (showDetails) ...[
|
||||
SizedBox(height: 8.h),
|
||||
|
||||
// 角色名稱
|
||||
Text(
|
||||
character.name,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 4.h),
|
||||
|
||||
// 角色職業
|
||||
Text(
|
||||
character.role,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
/// 對話背景組件
|
||||
class DialogueBackground extends StatelessWidget {
|
||||
final String scenarioId;
|
||||
final String? backgroundUrl;
|
||||
|
||||
const DialogueBackground({
|
||||
super.key,
|
||||
required this.scenarioId,
|
||||
this.backgroundUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 背景圖片
|
||||
if (backgroundUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: backgroundUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[800],
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[800],
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
color: Colors.grey[600],
|
||||
size: 64,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.blue[900]!,
|
||||
Colors.purple[900]!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 漸層覆蓋層
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.3),
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.7),
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import '../models/dialogue_models.dart';
|
||||
|
||||
/// 對話氣泡組件
|
||||
class DialogueBubble extends StatelessWidget {
|
||||
final DialogueMessage dialogue;
|
||||
final bool isUserReply;
|
||||
|
||||
const DialogueBubble({
|
||||
super.key,
|
||||
required this.dialogue,
|
||||
required this.isUserReply,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: isUserReply ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
margin: EdgeInsets.symmetric(vertical: 8.h),
|
||||
padding: EdgeInsets.all(16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: isUserReply
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.white.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
dialogue.content,
|
||||
style: TextStyle(
|
||||
color: isUserReply ? Colors.white : Colors.black87,
|
||||
fontSize: 16.sp,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
/// 回覆輔助面板
|
||||
class ReplyAssistancePanel extends StatelessWidget {
|
||||
final List<String> suggestions;
|
||||
final Function(String) onSelectSuggestion;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const ReplyAssistancePanel({
|
||||
super.key,
|
||||
required this.suggestions,
|
||||
required this.onSelectSuggestion,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 320.w,
|
||||
height: 400.h,
|
||||
margin: EdgeInsets.all(20.w),
|
||||
padding: EdgeInsets.all(20.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'回覆建議',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onClose,
|
||||
icon: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: suggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = suggestions[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: 8.h),
|
||||
child: GestureDetector(
|
||||
onTap: () => onSelectSuggestion(suggestion),
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||