Compare commits
5 Commits
d31340a05a
...
9345654cc1
| Author | SHA1 | Date |
|---|---|---|
|
|
9345654cc1 | |
|
|
fc49d3b6d7 | |
|
|
8c79fd8ef6 | |
|
|
7ce6057fd5 | |
|
|
d44cfe511a |
|
|
@ -72,7 +72,26 @@
|
||||||
"Bash(do echo -n \"$ui: \")",
|
"Bash(do echo -n \"$ui: \")",
|
||||||
"Bash(if grep -q \"$ui\" /tmp/system_ui_list.txt)",
|
"Bash(if grep -q \"$ui\" /tmp/system_ui_list.txt)",
|
||||||
"Bash(fi)",
|
"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": [],
|
"deny": [],
|
||||||
"ask": []
|
"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):
|
- path_provider_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- permission_handler_apple (9.3.0):
|
||||||
|
- Flutter
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- speech_to_text (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- Try
|
||||||
- sqflite_darwin (0.0.4):
|
- sqflite_darwin (0.0.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- Try (2.1.1)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||||
|
|
@ -26,9 +32,15 @@ DEPENDENCIES:
|
||||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||||
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/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`)
|
- 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`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
|
|
||||||
|
SPEC REPOS:
|
||||||
|
trunk:
|
||||||
|
- Try
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
audio_session:
|
audio_session:
|
||||||
:path: ".symlinks/plugins/audio_session/ios"
|
:path: ".symlinks/plugins/audio_session/ios"
|
||||||
|
|
@ -42,8 +54,12 @@ EXTERNAL SOURCES:
|
||||||
:path: ".symlinks/plugins/just_audio/darwin"
|
:path: ".symlinks/plugins/just_audio/darwin"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
|
permission_handler_apple:
|
||||||
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
speech_to_text:
|
||||||
|
:path: ".symlinks/plugins/speech_to_text/ios"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||||
|
|
||||||
|
|
@ -54,8 +70,11 @@ SPEC CHECKSUMS:
|
||||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||||
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
|
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
|
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||||
|
speech_to_text: b43a7d99aef037bd758ed8e45d79bbac035d2dfe
|
||||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||||
|
Try: 5ef669ae832617b3cee58cb2c6f99fb767a4ff96
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|
||||||
|
|
@ -199,6 +199,7 @@
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
B7DA006F490B39DC5DD7D624 /* [CP] Embed Pods Frameworks */,
|
B7DA006F490B39DC5DD7D624 /* [CP] Embed Pods Frameworks */,
|
||||||
|
7328AAE839104E6E980FA1B3 /* [CP] Copy Pods Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
|
|
@ -308,6 +309,23 @@
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
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 */ = {
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
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/login_screen.dart';
|
||||||
import '../../features/auth/screens/register_screen.dart';
|
import '../../features/auth/screens/register_screen.dart';
|
||||||
import '../../features/learning/screens/home_screen.dart';
|
import '../../features/learning/screens/home_screen.dart';
|
||||||
|
import '../../features/dialogue/screens/dialogue_main_screen.dart';
|
||||||
import '../../shared/providers/auth_provider.dart';
|
import '../../shared/providers/auth_provider.dart';
|
||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
|
|
@ -52,9 +53,17 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dialogue',
|
path: '/dialogue',
|
||||||
builder: (context, state) => const Scaffold(
|
builder: (context, state) {
|
||||||
body: Center(child: Text('對話練習頁面')),
|
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(
|
GoRoute(
|
||||||
path: '/challenge',
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||