Compare commits

...

5 Commits

Author SHA1 Message Date
鄭沛軒 9345654cc1 refactor: complete project structure reorganization and SOP implementation
- Reorganize project structure to unified apps/ directory
  - Move src/backend/ → apps/backend/ (complete .NET Core API)
  - Move src/mobile/ → apps/mobile/ (complete Flutter app)
  - Keep apps/web/ as Vue.js frontend
  - Remove duplicate src/ directory structure

- Implement comprehensive SOP (Standard Operating Procedures)
  - Create sop/ unified management directory
  - Move CLAUDE.md → sop/docs/CLAUDE.md with updated guidelines
  - Move tools/, scripts/, archive/ → sop/ for centralized management
  - Establish three-tier task management architecture

- Create unified task management system
  - Rename TASK_MANAGEMENT.md → TASKS.md for simplicity
  - Integrate 17 UI design tasks from ui-design-tasks.md
  - Update task priority classification (🔥緊急/⚠️重要/📝一般/💡想法)
  - Update ./dl script for new file paths

- Archive obsolete systems and files
  - Archive old reports/ directory (replaced by sop/archive/)
  - Archive duplicate template files violating SOP principles
  - Clean up src/ directory documentation and configs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 23:53:01 +08:00
鄭沛軒 fc49d3b6d7 feat: add Web platform function specification template
## 📋 Web Platform Template Features

### Template Structure
- **Platform-specific sections**: Web端特色功能、Web專用頁面
- **UI naming convention**: Page_*_W format for Web pages
- **Keyboard shortcuts**: Complete shortcut system section
- **Responsive design**: Desktop-first responsive specifications
- **Web API integration**: Modern Web APIs and features
- **Cross-platform mapping**: References to corresponding mobile specs

### Web-Specific Enhancements
- **Layout specifications**: Multi-pane, sidebar, toolbar layouts
- **Interaction patterns**: Mouse, keyboard, drag-and-drop operations
- **Browser compatibility**: Cross-browser testing requirements
- **Performance optimization**: Web-specific performance strategies
- **Accessibility**: WCAG compliance and keyboard navigation
- **Enterprise features**: SSO, compliance, bulk operations

### Development Guidelines
- **Frontend framework**: React/Vue/Angular recommendations
- **State management**: Web-specific state management patterns
- **Build tools**: Webpack/Vite configuration guidance
- **Testing strategy**: Browser compatibility and performance testing
- **Code organization**: Web platform development best practices

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 16:03:33 +08:00
鄭沛軒 8c79fd8ef6 feat: complete comprehensive Web platform function specifications
## 🌐 Complete Web Platform Architecture

### New Web-Specific Function Specifications (5 Complete Modules)
- **情境對話功能規格_Web.md**: Immersive dialogue with dual-pane layout, multi-tab support
- **學習地圖功能規格_Web.md**: Interactive map with zoom/pan, learning path planner
- **道具商店功能規格_Web.md**: E-commerce grade shopping with cart, subscription management
- **用戶認證功能規格_Web.md**: Enterprise SSO, WebAuthn, GDPR compliance
- **詞彙學習功能規格_Web.md**: Enhanced with analytics dashboard, keyboard shortcuts

### Web Platform Advantages
- **Desktop-First Design**: Optimized for large screens and multi-window workflows
- **Advanced Interactions**: Full keyboard shortcuts, drag-and-drop, batch operations
- **Enterprise Features**: SSO integration, bulk management, compliance tools
- **Professional Analytics**: Detailed dashboards, data export, comparison tools
- **Modern Web APIs**: WebAuthn, Web Speech, WebRTC, Service Workers

### Technical Specifications
- **Total Pages**: ~245 pages of detailed Web specifications
- **Page Coverage**: 32 main pages + 14 Web-exclusive pages
- **UI Naming**: Consistent Page_*_W format (vs Mobile UI_*)
- **Keyboard Support**: Complete shortcut systems for all functions
- **Responsive Design**: Desktop-first with tablet/mobile fallbacks

### Architecture Benefits
- **Platform Specialization**: Web-specific features without mobile constraints
- **Development Efficiency**: Specialized specs for Web development teams
- **Enterprise Market**: B2B features for corporate and educational users
- **Technical Excellence**: Modern web standards and best practices

### Cross-Platform Consistency
- **Functional Parity**: 85-100% feature overlap with mobile platform
- **Shared Business Logic**: Common rules, data models, APIs maintained
- **Platform Mapping**: Complete correspondence table for development sync
- **Quality Assurance**: Unified testing and validation standards

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 16:00:05 +08:00
鄭沛軒 7ce6057fd5 refactor: reorganize function specs by platform (mobile/web/common)
## 🏗️ Platform-based Architecture Restructure

### Directory Structure Changes
- **mobile/**: Mobile-specific specifications (5 complete function specs)
- **web/**: Web-specific specifications (1 complete, 4 planned)
- **common/**: Cross-platform shared specifications (3 core docs)
- **Platform mapping**: Complete correspondence table between platforms

### New Cross-platform Common Specifications
- 業務規則.md: Shared business logic (life points, economy, achievements)
- 數據模型.md: Unified data models (User, Vocabulary, Dialogue, etc.)
- API規格.md: Platform-agnostic API specifications

### Web Platform Specifications (Sample)
- 詞彙學習功能規格_Web.md: Complete web vocabulary learning spec
- Enhanced features: keyboard shortcuts, multi-tab support, advanced analytics
- UI naming: Page_*_W format (vs Mobile UI_* format)

### Platform Correspondence System
- 平台功能對應表.md: Complete mobile-web feature mapping
- Functionality overlap: 85-100% feature parity
- Platform-specific features: 6 mobile-only, 7 web-only features
- Development priority matrix and sync strategies

### Benefits for AI Collaboration
- **Token efficiency**: 50%+ reduction by loading platform-specific specs
- **Context clarity**: Eliminates mixed-platform logic confusion
- **Maintenance**: Independent platform updates without cross-contamination
- **Scalability**: Ready for future platform additions

### Mobile App Development Progress
- Added comprehensive Flutter dialogue feature implementation
- Voice recognition service and provider setup
- Complete dialogue UI component library
- Updated app router and dependencies

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 15:47:05 +08:00
鄭沛軒 d44cfe511a feat: complete mobile app function specifications and API documentation
- Add comprehensive function specifications for 5 core mobile app features:
  * 01_情境對話功能規格.md - Situational dialogue system
  * 02_詞彙學習功能規格.md - Vocabulary learning system
  * 03_學習地圖功能規格.md - Learning map and progress system
  * 04_道具商店功能規格.md - In-app store and items system
  * 05_用戶認證功能規格.md - User authentication system
- Add swagger-ui.html with complete API documentation and testing interface
- Include detailed UI specifications, business logic, and integration requirements
- Establish foundation for mobile app development

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 15:38:18 +08:00
285 changed files with 52045 additions and 185 deletions

View File

@ -72,7 +72,26 @@
"Bash(do echo -n \"$ui: \")",
"Bash(if grep -q \"$ui\" /tmp/system_ui_list.txt)",
"Bash(fi)",
"Bash(done)"
"Bash(done)",
"Bash(/Users/jettcheng1018/flutter/bin/flutter run -d 148D878C-62EB-4B60-9C04-2173EC0248BF)",
"Bash(/Users/jettcheng1018/flutter/bin/flutter run -d Medium_Phone_API_36.0)",
"Bash(/Users/jettcheng1018/flutter/bin/flutter emulators --launch Medium_Phone_API_36.0)",
"Bash(dotnet run:*)",
"Bash(dotnet --version)",
"Bash(npm install)",
"Bash(npm install:*)",
"Bash(npm run dev:*)",
"Bash(find:*)",
"Bash(/bashes)",
"Bash(pkill:*)",
"Bash(say:*)",
"Bash(./dl list)",
"Bash(./dl task)",
"Bash(./scripts/file_version_manager.sh:*)",
"Bash(./scripts/archive_file.sh:*)",
"Bash(./scripts/view_archives.sh:*)",
"Bash(tree:*)",
"Bash(./sop/scripts/archive_file.sh:*)"
],
"deny": [],
"ask": []

143
TASKS.md Normal file
View File

@ -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%完整覆蓋

27
apps/README.md Normal file
View File

@ -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`

45
apps/backend/README.md Normal file
View File

@ -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/`

43
apps/mobile/README.md Normal file
View File

@ -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/`

View File

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 544 B

View File

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 442 B

View File

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 721 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -12,12 +12,18 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- speech_to_text (0.0.1):
- Flutter
- Try
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- Try (2.1.1)
DEPENDENCIES:
- audio_session (from `.symlinks/plugins/audio_session/ios`)
@ -26,9 +32,15 @@ DEPENDENCIES:
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- speech_to_text (from `.symlinks/plugins/speech_to_text/ios`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
SPEC REPOS:
trunk:
- Try
EXTERNAL SOURCES:
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
@ -42,8 +54,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/just_audio/darwin"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
speech_to_text:
:path: ".symlinks/plugins/speech_to_text/ios"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
@ -54,8 +70,11 @@ SPEC CHECKSUMS:
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
speech_to_text: b43a7d99aef037bd758ed8e45d79bbac035d2dfe
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
Try: 5ef669ae832617b3cee58cb2c6f99fb767a4ff96
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@ -199,6 +199,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
B7DA006F490B39DC5DD7D624 /* [CP] Embed Pods Frameworks */,
7328AAE839104E6E980FA1B3 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@ -308,6 +309,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
7328AAE839104E6E980FA1B3 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;

View File

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

View File

@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../features/auth/screens/login_screen.dart';
import '../../features/auth/screens/register_screen.dart';
import '../../features/learning/screens/home_screen.dart';
import '../../features/dialogue/screens/dialogue_main_screen.dart';
import '../../shared/providers/auth_provider.dart';
final routerProvider = Provider<GoRouter>((ref) {
@ -52,9 +53,17 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: '/dialogue',
builder: (context, state) => const Scaffold(
body: Center(child: Text('對話練習頁面')),
),
builder: (context, state) {
final scenarioId = state.uri.queryParameters['scenarioId'] ?? 'restaurant_001';
final levelId = state.uri.queryParameters['levelId'] ?? 'level_001';
final isTimeChallenge = state.uri.queryParameters['timeChallenge'] == 'true';
return DialogueMainScreen(
scenarioId: scenarioId,
levelId: levelId,
isTimeChallenge: isTimeChallenge,
);
},
),
GoRoute(
path: '/challenge',

View File

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

View File

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

View File

@ -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('查看結果'),
),
],
),
);
}
}

View File

@ -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 '我明白了。還有什麼我可以為您服務的嗎?';
}
}
}

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More