diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md new file mode 100644 index 0000000..f4c0b9d --- /dev/null +++ b/docs/DEMO_SCRIPT.md @@ -0,0 +1,141 @@ +# ClawFree — 3-Minute Demo Video Script + +## Overview +- **Duration**: 3:00 +- **Target**: Anthropic "Built with Opus 4.6" Hackathon judges +- **Scoring**: Demo 30% | Impact 25% | Opus 4.6 Use 25% | Depth 20% +- **Devices**: MacBook (macOS), iPhone, iPad, Apple Watch + +--- + +## Scene 1: Opening Hook (0:00 - 0:15) + +**Visual**: Dark screen → ClawFree logo fade in → tagline appears + +**Voiceover**: +> "What if your AI wasn't trapped behind a keyboard? What if you could talk to it from your watch, your phone, your tablet — and it built the UI for you in real-time?" + +**Text on screen**: "ClawFree — Hands-free AI, everywhere you are" + +--- + +## Scene 2: Architecture Overview (0:15 - 0:30) + +**Visual**: Animated diagram showing 4 devices → OpenClaw Gateway → Opus 4.6 → genUI Surface + +**Voiceover**: +> "ClawFree connects your Apple Watch, iPhone, iPad, and Mac to OpenClaw — your local AI gateway. Every interaction is powered by Opus 4.6, which generates rich, interactive UIs on the fly using our A2UI protocol." + +--- + +## Scene 3: Voice Trip Planning — Watch (0:30 - 1:15) + +**Visual**: Apple Watch on wrist / Watch simulator + +**Action**: +1. Tap mic on Watch → speak: "Plan a 3-day trip to Tokyo" +2. Watch shows "Sending..." → "Planning..." +3. Cut to iPhone showing genUI Surface rendering: + - Day-by-day itinerary cards + - Budget breakdown + - Map preview +4. TTS plays: "Here's a 3-day Tokyo itinerary..." + +**Voiceover**: +> "Start from your watch. Just speak — Opus 4.6 generates a complete travel planner with interactive cards, budget estimates, and daily schedules. No templates. Every UI is generated fresh." + +**Key moment**: Show the A2UI JSON briefly → transformed into beautiful UI + +--- + +## Scene 4: Voice Follow-up — iPhone (1:15 - 1:45) + +**Visual**: iPhone screen + +**Action**: +1. VoiceOrb pulses → user speaks: "Add Osaka on day 2" +2. genUI Surface updates — Osaka card appears in Day 2 +3. User speaks: "What's the budget now?" +4. Budget card updates with new total +5. User taps "Book this itinerary" button on genUI surface + +**Voiceover**: +> "Continue the conversation hands-free. Ask follow-ups, and the UI evolves. Mix voice and touch — tap to confirm, speak to explore. Opus 4.6 maintains context across turns." + +--- + +## Scene 5: Control Tower — macOS (1:45 - 2:15) + +**Visual**: macOS dashboard (TabletLayout) + +**Action**: +1. Show full Control Tower: sidebar with agents, telemetry header with health vitals +2. Connected devices showing: iPhone ✅, Watch ✅, iPad ✅ +3. Main surface showing the trip plan from iPhone +4. Type or speak: "Create an agent called TripBot specialized in Japan travel" +5. genUI renders agent configuration form +6. Fill settings → Save + +**Voiceover**: +> "The Mac is your command center. See every connected device, monitor your AI's health, and manage agents. Here we're creating a specialized travel agent — Opus 4.6 generates the entire configuration UI from a single voice command." + +--- + +## Scene 6: Multi-device Sync — iPad (2:15 - 2:35) + +**Visual**: iPad simulator showing TabletLayout + +**Action**: +1. iPad shows same trip plan synced from iPhone session +2. Pinch to zoom on map +3. Voice: "Show me hotel options near Shibuya" +4. genUI renders hotel comparison cards + +**Voiceover**: +> "Every device stays in sync through OpenClaw. Pick up where you left off on any screen. The iPad's larger display shows the full power of genUI surfaces." + +--- + +## Scene 7: The Vision — Ambient Computing (2:35 - 2:50) + +**Visual**: Split screen showing all 4 devices simultaneously + +**Voiceover**: +> "This is ambient computing. Your AI is always there — on your wrist for quick commands, in your pocket for deeper interactions, on your desk for full control. No cloud dependency — everything runs on your local network. Today it's powered by Opus 4.6. Tomorrow, it could run on local models for zero-cost, always-on intelligence." + +--- + +## Scene 8: Closing (2:50 - 3:00) + +**Visual**: ClawFree logo + tech stack badges + GitHub link + +**Text on screen**: +- "Built with Opus 4.6 + genUI v0.9 + OpenClaw" +- "Team genUIne: Mike & Roy" +- GitHub: github.com/dart-technologies/clawfree + +**Voiceover**: +> "ClawFree. Your AI, unbound." + +--- + +## Production Notes + +### Recording Tips +- Screen record each device separately, composite in editor +- Use QuickTime for Mac/iPad simulator recording +- iPhone: either real device or simulator +- Watch: simulator is fine (hardware not required) +- Add subtle background music (lo-fi / ambient) +- Transitions: cross-dissolve between scenes + +### Demo Mode +- Use `DEMO_MODE=true` for cached responses (reliable) +- For Scene 3-4, consider one LIVE Opus 4.6 call to show real generation +- Have backup cached responses ready + +### Tools +- **Screen recording**: QuickTime Player / OBS +- **Video editing**: iMovie / DaVinci Resolve (free) +- **Captions**: Add key text overlays for architecture/protocol +- **Music**: freemusicarchive.org or YouTube Audio Library diff --git a/docs/PITCH_SPEECH.md b/docs/PITCH_SPEECH.md new file mode 100644 index 0000000..fb4f424 --- /dev/null +++ b/docs/PITCH_SPEECH.md @@ -0,0 +1,67 @@ +# ClawFree — Demo Day Pitch Speech + +> Duration: ~2 minutes (before or after 3-min video) +> Speaker: Roy (with Mike) + +--- + +## The Speech + +Hi everyone, I'm Roy, and this is Mike. We're **Team genUIne**. + +We built **ClawFree** — a hands-free, voice-powered AI interface that runs on your watch, your phone, your tablet, and your Mac. All powered by **Opus 4.6**. + +But first, let me tell you how we got here. + +### How We Work + +Mike and I sync for **15 minutes a day**. That's it. The rest of the time, we iterate with AI. We don't waste time on meetings. We deliver, review, iterate. + +Mike brought the vision — what if AI could generate its own UI? He built **genUI**, an A2UI protocol that lets Opus 4.6 create rich, interactive interfaces from scratch. No templates. No pre-built screens. + +I brought the voice — what if you never had to touch a keyboard? I built the multi-device voice layer with OpenClaw, so you can speak from your Watch and see the result on your iPad. + +### Why Opus 4.6 — Everywhere + +Here's what makes us different. Most teams use Opus for one thing — a chatbot, a tool, a feature. + +**We used Opus 4.6 for everything.** + +- **Ideation**: We brainstormed with Opus. Evaluated impact. Refined our pitch. +- **Development**: Opus wrote the code. We iterated with voice commands — literally zero keyboard. +- **Product**: Opus generates every UI at runtime through genUI. Every screen you'll see was created by Opus 4.6 in real-time. +- **Marketing**: This website, this demo script, even this speech — Opus helped craft all of it. +- **Validation**: We used Opus to work backwards from judging criteria, identify gaps, and strengthen our weak spots. + +The human decides **what matters**. Opus 4.6 figures out **how to make it happen**. + +### The Vision + +In the AI era, the question isn't "how do we build it?" — AI can figure that out. The question is: **"What should we build that influences the most people?"** + +ClawFree is our answer. Ambient AI. On your wrist, in your pocket, on your desk. Always there, always ready. No keyboard required. + +Today it runs on Opus 4.6. Tomorrow, with local models on a Mac Studio, it becomes always-on, zero-cost intelligence. + +### Closing + +We're excited to be here. Thank you to Anthropic, to Claude, to the Cerebral Valley community. + +In this AI era, there are more challenges than ever — but also more opportunities than ever. And we're thrilled to be building at this frontier. + +**ClawFree. Your AI, unbound.** + +Thank you. + +--- + +## Speaker Notes + +- **Tone**: Confident but humble. Excited but grounded. +- **Pace**: Don't rush. Let key lines land. +- **Key moments to emphasize**: + - "15 minutes a day" — pause, let it sink in + - "Zero keyboard" — demonstrate with gesture + - "What should we build that influences the most people?" — this is the thesis +- **If time is short**: Cut the "How We Work" section, jump straight to Opus 4.6 usage +- **Language**: Primarily English for judges. Roy can mix in passion/energy naturally. diff --git a/docs/WATCH-COMMUNICATION.md b/docs/WATCH-COMMUNICATION.md new file mode 100644 index 0000000..ee86592 --- /dev/null +++ b/docs/WATCH-COMMUNICATION.md @@ -0,0 +1,191 @@ +# Watch Communication Architecture + +## Overview + +ClawFree supports bidirectional communication between Apple Watch and all client devices (iPhone, iPad, macOS). Since Apple's WatchConnectivity (WCSession) only works between a paired iPhone and Watch, we use a **Gateway Relay** to extend Watch communication to iPad and macOS. + +## Architecture Diagram + +``` + ┌─────────────────┐ + │ Apple Watch │ + │ (watchOS app) │ + │ │ + │ Voice dictation │ + │ → recognized │ + │ text │ + └────────┬─────────┘ + │ + WCSession (native) + (only works with iPhone) + │ + ┌────────▼─────────┐ + │ iPhone │ + │ (Flutter app) │ + │ │ + │ Receives Watch │ + │ events via │ + │ WCSession + │ + │ broadcasts to │ + │ Gateway Relay │ + └────────┬─────────┘ + │ + POST /watch/relay + │ + ┌────────▼─────────┐ + │ Gateway Server │ + │ (Node.js) │ + │ │ + │ /watch/relay │ + │ SSE endpoint │ + └───┬─────────┬────┘ + │ │ + SSE stream SSE stream + │ │ + ┌────────────▼┐ ┌────▼────────────┐ + │ iPad │ │ macOS │ + │ (Flutter) │ │ (Flutter) │ + │ │ │ │ + │ Relay mode │ │ Relay mode │ + └─────────────┘ └─────────────────┘ +``` + +## Communication Modes + +### 1. iPhone — Direct (WCSession) + +iPhone is the only device that can communicate directly with Apple Watch via Apple's WatchConnectivity framework. + +**Watch → iPhone flow:** +1. User speaks on Watch → watchOS native dictation converts speech to text +2. Watch sends text via `WCSession.sendMessage()` or `transferFile()` +3. `AppDelegate.swift` receives the message in `WCSessionDelegate` callbacks +4. Event is forwarded to Flutter via `FlutterEventChannel` (`art.dart.clawfree/watch_events`) +5. `WatchBridge.onVoiceReceived` stream emits a `WatchVoiceEvent` +6. iPhone also calls `WatchBridge.broadcastToRelay()` to POST the event to the gateway + +**iPhone → Watch flow:** +1. Flutter calls `WatchBridge.sendReplyToWatch(text)` +2. Invokes native `MethodChannel` (`art.dart.clawfree/watch`) with method `sendReply` +3. `AppDelegate.swift` sends via `WCSession.default.sendMessage()` +4. Watch receives and displays the AI reply + +### 2. iPad & macOS — Gateway Relay (SSE) + +iPad and macOS cannot use WCSession. They communicate with Watch indirectly through the Gateway Relay. + +**Watch → iPad/macOS flow:** +1. Watch sends voice command to iPhone (via WCSession) +2. iPhone receives it and POSTs to `GET /watch/relay` subscribers via `POST /watch/relay` +3. Gateway broadcasts the event as SSE to all connected clients +4. iPad/macOS receive the event through their SSE subscription +5. `WatchBridge._relayController` emits the `WatchVoiceEvent` + +**iPad/macOS → Watch flow:** +1. Flutter calls `WatchBridge.sendReplyToWatch(text)` +2. In relay mode, this POSTs to `POST /watch/relay` with `source: "ipad"` +3. Gateway broadcasts to all SSE subscribers +4. iPhone receives the relay event, detects `source: "ipad"` + `type: "ai_reply"` +5. iPhone forwards to Watch via WCSession `sendMessage()` + +## Key Files + +| File | Role | +|------|------| +| `lib/src/core/watch_bridge.dart` | Flutter-side bridge — dual mode (WCSession / relay) | +| `ios/Runner/AppDelegate.swift` | Native iOS — WCSession delegate + platform channels | +| `ios/WatchCompanion/` | watchOS app — voice dictation + WCSession | +| `infra/gateway/server.js` | Gateway — `/watch/relay` SSE endpoint | + +## Platform Channels + +| Channel | Type | Direction | Purpose | +|---------|------|-----------|---------| +| `art.dart.clawfree/watch` | MethodChannel | Flutter → Native | `syncWatch`, `sendReply`, `isWatchReachable` | +| `art.dart.clawfree/watch_events` | EventChannel | Native → Flutter | Watch voice events stream | + +## Gateway Relay API + +### `GET /watch/relay` + +Subscribe to Watch events via Server-Sent Events (SSE). + +**Response:** SSE stream. Each event is a JSON object: + +```json +// Connection handshake +{"type": "connected"} + +// Voice command from Watch (relayed by iPhone) +{"type": "voice_command", "text": "check my schedule", "timestamp": 1707840000000, "source": "iphone"} + +// AI reply from iPad/macOS +{"type": "ai_reply", "text": "Here's your schedule...", "timestamp": 1707840001000, "source": "ipad"} +``` + +### `POST /watch/relay` + +Broadcast an event to all SSE subscribers. + +**Request body:** JSON object (same format as SSE events above) + +**Response:** +```json +{"ok": true, "subscribers": 2} +``` + +## Device Detection Logic + +```dart +// In WatchBridge.configure(): +if (Platform.isIOS) { + // Try WCSession — works on iPhone, fails on iPad + try { + await methodChannel.invokeMethod('isWatchReachable'); + // Success → iPhone mode (direct WCSession) + } on MissingPluginException { + // Fail → iPad mode (gateway relay) + } +} else if (Platform.isMacOS) { + // macOS always uses gateway relay +} +``` + +## Prerequisites + +1. **iPhone ↔ Watch pairing** (simulator: `xcrun simctl pair `) +2. **Gateway server running** for iPad/macOS relay: `cd infra/gateway && node server.js` +3. **WatchCompanion app installed** on Watch (embedded via Xcode target dependency) + +--- + +## 繁體中文摘要 + +### 概述 + +ClawFree 支援 Apple Watch 與所有裝置(iPhone、iPad、macOS)之間的雙向通訊。Apple 的 WCSession 僅支援 iPhone ↔ Watch,因此 iPad 和 macOS 透過 **Gateway Relay(閘道中繼)** 間接通訊。 + +### 通訊模式 + +| 路徑 | 方式 | +|------|------| +| Watch ↔ iPhone | WCSession 直連(原生 WatchConnectivity) | +| Watch ↔ iPad | Gateway SSE Relay(iPad 不支援 WCSession) | +| Watch ↔ macOS | Gateway SSE Relay(macOS 不支援 WCSession) | + +### 流程 + +**Watch → iPhone(直連):** +使用者在 Watch 說話 → watchOS 聽寫辨識為文字 → WCSession 傳至 iPhone → Flutter EventChannel 接收 → 同時廣播至 Gateway Relay + +**Watch → iPad/macOS(中繼):** +Watch → iPhone(WCSession)→ iPhone POST 至 Gateway `/watch/relay` → Gateway SSE 廣播 → iPad/macOS 接收 + +**iPad/macOS → Watch(中繼):** +iPad/macOS POST 至 Gateway → iPhone 收到 SSE 事件 → iPhone 透過 WCSession 轉發至 Watch + +### 前置條件 + +1. iPhone ↔ Watch 需配對(模擬器:`xcrun simctl pair `) +2. iPad/macOS relay 需啟動 Gateway:`cd infra/gateway && node server.js` +3. WatchCompanion app 需安裝於 Watch(透過 Xcode target dependency 嵌入) diff --git a/docs/website/index.html b/docs/website/index.html new file mode 100644 index 0000000..f7c0ecb --- /dev/null +++ b/docs/website/index.html @@ -0,0 +1,302 @@ + + + + + + ClawFree — Hands-free AI, everywhere you are + + + + + + + + + +
+ ClawFree Logo +

Hands-free AI,
everywhere you are

+

Voice-powered genUI frontend for OpenClaw, built with Opus 4.6

+ +
+ + +
+

What is ClawFree?

+

A multi-device, voice-first AI interface. Speak to create, configure, and interact with AI agents through dynamically generated UIs — from your Apple Watch to your Mac. No hands required.

+ +
+
Architecture
+
+
+
⌚ Watch
+
📱 iPhone
+
📱 iPad
+
💻 macOS
+
+ +
OpenClaw Gateway
+ +
Opus 4.6
+
+
+ +
+
+
genUI Surface
+
+
+
+ + +
+

Demo Scenarios

+
+
+
🗾
+

Voice Trip Planning

+

Plan your trip entirely by voice. genUI dynamically generates itineraries, maps, and booking options as you speak.

+
+
+
🖥️
+

Multi-device Control Tower

+

Four devices in sync. Ambient computing across Watch, iPhone, iPad, and Mac — each showing contextual UI surfaces.

+
+
+
🤖
+

Agent Management

+

Create, configure, and manage AI agents with natural voice commands. No code, no menus — just speak.

+
+
+
+ + +
+

Tech Stack

+
+
Opus 4.6 + A2UI Protocol + genUI v0.9
+
Flutter — iOS / iPad / macOS / Watch
+
OpenClaw Gateway — Local-first architecture
+
WatchConnectivity + WebSocket
+
+
+ + +
+

See it in Action

+
+
+
+

3-minute demo — coming soon

+
+
+
+ + +
+

The Team

+

Team genUIne

+
+
+
M
+

Mike

+

Flutter / genUI / Infrastructure

+
+
+
R
+

Roy

+

Voice / OpenClaw / Watch

+
+
+
+ +
+
Built for Anthropic "Built with Opus 4.6" Hackathon
+
Cerebral Valley · Feb 10–16, 2026
+ +
+ + + + + diff --git a/infra/gateway/server.js b/infra/gateway/server.js index 9c4f4a2..6547478 100644 --- a/infra/gateway/server.js +++ b/infra/gateway/server.js @@ -31,6 +31,11 @@ const CORS_HEADERS = { 'Access-Control-Expose-Headers': 'Content-Type', }; +// --------------------------------------------------------------------------- +// Watch Relay — in-memory event bus for iPhone ↔ iPad ↔ Watch communication +// --------------------------------------------------------------------------- +const watchRelayClients = new Set(); // SSE subscribers + const server = http.createServer((req, res) => { // CORS preflight if (req.method === 'OPTIONS') { @@ -63,6 +68,38 @@ const server = http.createServer((req, res) => { return; } + // Watch Relay endpoint — SSE subscribe (GET) or broadcast (POST) + if (req.url === '/watch/relay') { + if (req.method === 'GET') { + // SSE stream — iPad or iPhone subscribes here + res.writeHead(200, { + ...CORS_HEADERS, + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write('data: {"type":"connected"}\n\n'); + watchRelayClients.add(res); + req.on('close', () => watchRelayClients.delete(res)); + return; + } + if (req.method === 'POST') { + // Broadcast event to all SSE subscribers + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const payload = Buffer.concat(chunks).toString(); + for (const client of watchRelayClients) { + client.write(`data: ${payload}\n\n`); + } + res.writeHead(200, { ...CORS_HEADERS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, subscribers: watchRelayClients.size })); + }); + return; + } + } + // Determine proxy target based on path const isAnthropicPath = req.url.startsWith('/v1/'); const isOpenClawPath = req.url.startsWith('/agents') || diff --git a/ios/Podfile b/ios/Podfile index 620e46e..5bd0dab 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '13.0' +platform :ios, '16.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -39,5 +39,21 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + # Remove arm64 exclusion for simulator — required for Apple Silicon + target.build_configurations.each do |config| + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'i386' + end + end + # Also fix the aggregate targets (Pods-Runner, etc.) + installer.pods_project.build_configurations.each do |config| + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'i386' + end + # Fix generated xcconfig files that CocoaPods writes before post_install + Dir.glob(File.join(installer.sandbox.root, 'Target Support Files', '**', '*.xcconfig')).each do |xcconfig_path| + content = File.read(xcconfig_path) + if content.include?('EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64') + content.gsub!('EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64', 'EXCLUDED_ARCHS[sdk=iphonesimulator*] = i386') + File.write(xcconfig_path, content) + end end end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3daa801..5632f23 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,19 +7,26 @@ objects = { /* Begin PBXBuildFile section */ - 06F0DBF70E0ADB63A59DE84C /* ConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECFCEB1F8047B9579A1D484 /* ConnectivityProvider.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 1BF4F30798C8C819712A0780 /* PulseMonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71CF57D486DE99BC1A984C5 /* PulseMonitorView.swift */; }; + 2746D78B22E061B2C1BBEB3A /* WatchDemoFlowTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEBB6BF4457DEDD44F786EA1 /* WatchDemoFlowTest.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 483159FF0549F576CB41064B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0F2F43C2BC8F51A224D0568 /* Foundation.framework */; }; + 5067092B572DA3867142BD27 /* WatchCompanion.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 5731CB3D7A8BF86FF55E0F2B /* WatchCompanion.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 888581C3F9047B3049648DC9 /* WatchTTSService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6507D09E771C459BFCF9A8 /* WatchTTSService.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + AC02AGNT0002000000000001 /* AgentConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC01AGNT0001000000000001 /* AgentConfigView.swift */; }; C56A177890516E7D4121D5F3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8361AA685F6881606A0A38D5 /* Pods_Runner.framework */; }; - EF3AD48EABC896CC6728E963 /* WatchCompanionApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BDC3EC12FC19D81BE9A2C52 /* WatchCompanionApp.swift */; }; F0A22B58AED3C11E4BC89E5B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6664F43BA05FFC3058054D9 /* Pods_RunnerTests.framework */; }; + F9572DAC45AFDF1DB1FC399D /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0F2F43C2BC8F51A224D0568 /* Foundation.framework */; }; + TR02TRIP0002000000000001 /* TripPlannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TR01TRIP0001000000000001 /* TripPlannerView.swift */; }; + WC000001000000000000000A /* WatchCompanionApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BDC3EC12FC19D81BE9A2C52 /* WatchCompanionApp.swift */; }; + WC000002000000000000000A /* PulseMonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71CF57D486DE99BC1A984C5 /* PulseMonitorView.swift */; }; + WC000003000000000000000A /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D4E5F607890ABCDEF1 /* SpeechRecognizer.swift */; }; + WC000004000000000000000A /* ConnectivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECFCEB1F8047B9579A1D484 /* ConnectivityProvider.swift */; }; + WC000006000000000000000A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = WC000007000000000000000A /* Assets.xcassets */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -30,6 +37,20 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + D139FD88C597825E3B375990 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = WC000010000000000000000A; + remoteInfo = WatchCompanion; + }; + FC78BEBAA331B683A7B095A9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = WC000010000000000000000A; + remoteInfo = WatchCompanion; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -43,6 +64,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + A91DB425A36973E752860C1D /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 5067092B572DA3867142BD27 /* WatchCompanion.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -57,7 +89,7 @@ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 4ECFCEB1F8047B9579A1D484 /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = ""; }; - 5731CB3D7A8BF86FF55E0F2B /* WatchCompanion */ = {isa = PBXFileReference; includeInIndex = 0; path = WatchCompanion; sourceTree = BUILT_PRODUCTS_DIR; }; + 5731CB3D7A8BF86FF55E0F2B /* WatchCompanion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WatchCompanion.app; sourceTree = BUILT_PRODUCTS_DIR; }; 591AC1907D28C932A3C99091 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -72,16 +104,24 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A71CF57D486DE99BC1A984C5 /* PulseMonitorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PulseMonitorView.swift; sourceTree = ""; }; + AC01AGNT0001000000000001 /* AgentConfigView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AgentConfigView.swift; sourceTree = ""; }; + B1A2C3D4E5F607890ABCDEF1 /* SpeechRecognizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = ""; }; + BEBB6BF4457DEDD44F786EA1 /* WatchDemoFlowTest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WatchDemoFlowTest.swift; path = ../ios/WatchCompanionUITests/WatchDemoFlowTest.swift; sourceTree = ""; }; D6664F43BA05FFC3058054D9 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E0F2F43C2BC8F51A224D0568 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + E0F4477AB7CFC973950E10CE /* WatchCompanionUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WatchCompanionUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FF6507D09E771C459BFCF9A8 /* WatchTTSService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchTTSService.swift; sourceTree = ""; }; + TR01TRIP0001000000000001 /* TripPlannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripPlannerView.swift; sourceTree = ""; }; + WC000005000000000000000A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + WC000007000000000000000A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 03E2FE1824D06AC74DAC366B /* Frameworks */ = { + 1D9677B98FBFC048AFCA1B06 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 483159FF0549F576CB41064B /* Foundation.framework in Frameworks */, + F9572DAC45AFDF1DB1FC399D /* Foundation.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -101,9 +141,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + WC000012000000000000000A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1258372CCB35F5F405C96E07 /* WatchCompanionUITests */ = { + isa = PBXGroup; + children = ( + BEBB6BF4457DEDD44F786EA1 /* WatchDemoFlowTest.swift */, + ); + name = WatchCompanionUITests; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -122,7 +177,6 @@ 23EF3737A181479E61674C83 /* Pods-RunnerTests.release.xcconfig */, 24086B920E6F2FD9F67DF459 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -147,6 +201,7 @@ 6D64A068FD9DCF59A4EB25C6 /* Pods */, FD6733D9B511A757A4638EED /* Frameworks */, FFB705867388B9C4E0D47305 /* WatchCompanion */, + 1258372CCB35F5F405C96E07 /* WatchCompanionUITests */, ); sourceTree = ""; }; @@ -155,7 +210,8 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - 5731CB3D7A8BF86FF55E0F2B /* WatchCompanion */, + 5731CB3D7A8BF86FF55E0F2B /* WatchCompanion.app */, + E0F4477AB7CFC973950E10CE /* WatchCompanionUITests.xctest */, ); name = Products; sourceTree = ""; @@ -197,10 +253,16 @@ isa = PBXGroup; children = ( A71CF57D486DE99BC1A984C5 /* PulseMonitorView.swift */, + B1A2C3D4E5F607890ABCDEF1 /* SpeechRecognizer.swift */, 7BDC3EC12FC19D81BE9A2C52 /* WatchCompanionApp.swift */, 4ECFCEB1F8047B9579A1D484 /* ConnectivityProvider.swift */, + WC000005000000000000000A /* Info.plist */, + FF6507D09E771C459BFCF9A8 /* WatchTTSService.swift */, + AC01AGNT0001000000000001 /* AgentConfigView.swift */, + TR01TRIP0001000000000001 /* TripPlannerView.swift */, + WC000007000000000000000A /* Assets.xcassets */, ); - name = WatchCompanion; + path = WatchCompanion; sourceTree = ""; }; /* End PBXGroup section */ @@ -225,23 +287,6 @@ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 7F3BF77656EE7313A966832E /* WatchCompanion */ = { - isa = PBXNativeTarget; - buildConfigurationList = 07C9DE8A6DCAC2CF34A33C37 /* Build configuration list for PBXNativeTarget "WatchCompanion" */; - buildPhases = ( - 2653B555030EE0D9E9D0A652 /* Sources */, - 03E2FE1824D06AC74DAC366B /* Frameworks */, - F4C6CF6F89E2C772F404C9FD /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = WatchCompanion; - productName = WatchCompanion; - productReference = 5731CB3D7A8BF86FF55E0F2B /* WatchCompanion */; - productType = "com.apple.product-type.application.watchapp"; - }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; @@ -252,18 +297,55 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + A91DB425A36973E752860C1D /* Embed Watch Content */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 8D7810F8D27A232F400F6BF0 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( + 68F982F44E72EE02D2956221 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + E662FA9AB9D2FF8AB74897E6 /* WatchCompanionUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33BA911FBE352CA6E9002B28 /* Build configuration list for PBXNativeTarget "WatchCompanionUITests" */; + buildPhases = ( + 0B7EB1E65F1B6AE0B622AB13 /* Sources */, + 1D9677B98FBFC048AFCA1B06 /* Frameworks */, + 58BC0BC65A30D7113A8003F7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 99F8C7560A7909C204CF85AD /* PBXTargetDependency */, + ); + name = WatchCompanionUITests; + productName = WatchCompanionUITests; + productReference = E0F4477AB7CFC973950E10CE /* WatchCompanionUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + WC000010000000000000000A /* WatchCompanion */ = { + isa = PBXNativeTarget; + buildConfigurationList = WC000020000000000000000A /* Build configuration list for PBXNativeTarget "WatchCompanion" */; + buildPhases = ( + WC000011000000000000000A /* Sources */, + WC000012000000000000000A /* Frameworks */, + WC000013000000000000000A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WatchCompanion; + productName = WatchCompanion; + productReference = 5731CB3D7A8BF86FF55E0F2B /* WatchCompanion.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -282,6 +364,9 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + WC000010000000000000000A = { + CreatedOnToolsVersion = 15.0; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -299,7 +384,8 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, - 7F3BF77656EE7313A966832E /* WatchCompanion */, + WC000010000000000000000A /* WatchCompanion */, + E662FA9AB9D2FF8AB74897E6 /* WatchCompanionUITests */, ); }; /* End PBXProject section */ @@ -312,6 +398,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 58BC0BC65A30D7113A8003F7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -323,10 +416,11 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F4C6CF6F89E2C772F404C9FD /* Resources */ = { + WC000013000000000000000A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + WC000006000000000000000A /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -428,13 +522,11 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 2653B555030EE0D9E9D0A652 /* Sources */ = { + 0B7EB1E65F1B6AE0B622AB13 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1BF4F30798C8C819712A0780 /* PulseMonitorView.swift in Sources */, - EF3AD48EABC896CC6728E963 /* WatchCompanionApp.swift in Sources */, - 06F0DBF70E0ADB63A59DE84C /* ConnectivityProvider.swift in Sources */, + 2746D78B22E061B2C1BBEB3A /* WatchDemoFlowTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -455,6 +547,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + WC000011000000000000000A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + WC000001000000000000000A /* WatchCompanionApp.swift in Sources */, + WC000002000000000000000A /* PulseMonitorView.swift in Sources */, + WC000003000000000000000A /* SpeechRecognizer.swift in Sources */, + WC000004000000000000000A /* ConnectivityProvider.swift in Sources */, + 888581C3F9047B3049648DC9 /* WatchTTSService.swift in Sources */, + AC02AGNT0002000000000001 /* AgentConfigView.swift in Sources */, + TR02TRIP0002000000000001 /* TripPlannerView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -463,6 +569,18 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + 68F982F44E72EE02D2956221 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = WatchCompanion; + target = WC000010000000000000000A /* WatchCompanion */; + targetProxy = D139FD88C597825E3B375990 /* PBXContainerItemProxy */; + }; + 99F8C7560A7909C204CF85AD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = WatchCompanion; + target = WC000010000000000000000A /* WatchCompanion */; + targetProxy = FC78BEBAA331B683A7B095A9 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -485,24 +603,6 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ - 17273D6E2E3380639E57CD8F /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = WatchCompanion/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = art.dart.clawfree.watch; - SDKROOT = watchos; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; - }; - name = Debug; - }; 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { @@ -562,6 +662,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 3HUWM4L2MV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -626,20 +727,20 @@ }; name = Profile; }; - 67E68269E6C987905C451B89 /* Profile */ = { + 7290E88B80C6E882F5840D0D /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = WatchCompanion/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = art.dart.clawfree.watch; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = WatchCompanionUITests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.rollbytes.clawfree.WatchCompanionUITests; SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchsimulator watchos"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = WatchCompanion; VALIDATE_PRODUCT = YES; WATCHOS_DEPLOYMENT_TARGET = 10.0; }; @@ -763,6 +864,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 3HUWM4L2MV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -785,6 +887,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 3HUWM4L2MV; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -799,44 +902,134 @@ }; name = Release; }; - ACFD383F0359F72FAA5D5B50 /* Release */ = { + B30FC3F81E5E97E0CB0A047D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = WatchCompanionUITests/Info.plist; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.rollbytes.clawfree.WatchCompanionUITests; + SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchsimulator watchos"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = WatchCompanion; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + C0F8D64C46127EEF1D1C138B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = WatchCompanionUITests/Info.plist; + PRODUCT_BUNDLE_IDENTIFIER = com.rollbytes.clawfree.WatchCompanionUITests; + SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchsimulator watchos"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = WatchCompanion; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + WC000021000000000000000A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - GENERATE_INFOPLIST_FILE = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3HUWM4L2MV; + GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = WatchCompanion/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = art.dart.clawfree.watch; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = art.dart.clawfree.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + WC000022000000000000000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3HUWM4L2MV; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = WatchCompanion/Info.plist; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = art.dart.clawfree.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - VALIDATE_PRODUCT = YES; WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; + WC000023000000000000000A /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 3HUWM4L2MV; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = WatchCompanion/Info.plist; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = art.dart.clawfree.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 07C9DE8A6DCAC2CF34A33C37 /* Build configuration list for PBXNativeTarget "WatchCompanion" */ = { + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( - ACFD383F0359F72FAA5D5B50 /* Release */, - 17273D6E2E3380639E57CD8F /* Debug */, - 67E68269E6C987905C451B89 /* Profile */, + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + 33BA911FBE352CA6E9002B28 /* Build configuration list for PBXNativeTarget "WatchCompanionUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, + C0F8D64C46127EEF1D1C138B /* Release */, + B30FC3F81E5E97E0CB0A047D /* Debug */, + 7290E88B80C6E882F5840D0D /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -861,6 +1054,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + WC000020000000000000000A /* Build configuration list for PBXNativeTarget "WatchCompanion" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + WC000021000000000000000A /* Debug */, + WC000022000000000000000A /* Release */, + WC000023000000000000000A /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/WatchCompanion.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/WatchCompanion.xcscheme index 11b3751..fe6b458 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/WatchCompanion.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/WatchCompanion.xcscheme @@ -14,8 +14,8 @@ buildForAnalyzing = "YES"> diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/WatchCompanionUITests.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/WatchCompanionUITests.xcscheme new file mode 100644 index 0000000..b1c9f15 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/WatchCompanionUITests.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index cb30956..02768eb 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -9,14 +9,14 @@ import WatchConnectivity didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) - + // Watch Connectivity Setup if WCSession.isSupported() { let session = WCSession.default session.delegate = self session.activate() } - + // Flutter platform channels let controller: FlutterViewController = window?.rootViewController as! FlutterViewController let messenger = controller.binaryMessenger @@ -40,10 +40,25 @@ import WatchConnectivity let text = args["text"] as? String, WCSession.default.activationState == .activated, WCSession.default.isReachable { - WCSession.default.sendMessage(["aiReply": text], replyHandler: nil, errorHandler: nil) + WCSession.default.sendMessage(["aiReply": text], replyHandler: nil, errorHandler: { error in + print("[Watch] sendReply error: \(error.localizedDescription)") + }) } result(nil) + case "pingWatch": + if WCSession.default.activationState == .activated, + WCSession.default.isPaired, + WCSession.default.isReachable { + WCSession.default.sendMessage(["type": "ping"], replyHandler: { _ in + result(true) + }, errorHandler: { _ in + result(false) + }) + } else { + result(false) + } + case "isWatchReachable": let reachable = WCSession.default.activationState == .activated && WCSession.default.isPaired @@ -65,7 +80,13 @@ import WatchConnectivity // MARK: - WCSessionDelegate - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + if let error = error { + print("[Watch] activation error: \(error.localizedDescription)") + } else { + print("[Watch] activated, state=\(activationState.rawValue), paired=\(session.isPaired), reachable=\(session.isReachable)") + } + } func sessionDidBecomeInactive(_ session: WCSession) {} func sessionDidDeactivate(_ session: WCSession) { session.activate() @@ -85,27 +106,96 @@ import WatchConnectivity ] WatchEventStreamHandler.shared.send(event) } + + /// Receives real-time messages from the Watch (voice commands as text). + /// This variant handles messages sent WITH a replyHandler. + func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { + print("[Watch] didReceiveMessage (replyHandler): \(message)") + handleWatchVoiceCommand(message) + replyHandler(["status": "ok"]) + } + + /// Receives real-time messages from the Watch WITHOUT a replyHandler. + /// Fallback for messages sent without expecting a reply. + func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { + print("[Watch] didReceiveMessage (no reply): \(message)") + handleWatchVoiceCommand(message) + } + + /// Receives background user info transfers from the Watch. + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { + print("[Watch] didReceiveUserInfo: \(userInfo)") + handleWatchVoiceCommand(userInfo) + } + + /// Common handler for voice commands from Watch (any delivery method). + private func handleWatchVoiceCommand(_ message: [String: Any]) { + guard let type = message["type"] as? String else { return } + + if type == "voice_command", let text = message["text"] as? String { + let event: [String: Any] = [ + "type": "voice_command", + "text": text, + "timestamp": message["timestamp"] ?? Int(Date().timeIntervalSince1970 * 1000), + ] + WatchEventStreamHandler.shared.send(event) + } else if type == "ui_state" { + // Watch UI 狀態同步 → 直接轉發到 Flutter + WatchEventStreamHandler.shared.send(message) + } + } } -// MARK: - EventChannel stream handler +// MARK: - EventChannel stream handler with buffering class WatchEventStreamHandler: NSObject, FlutterStreamHandler { static let shared = WatchEventStreamHandler() private var eventSink: FlutterEventSink? + /// Buffer for events received before Flutter connects. + /// Prevents silent event loss when the app is woken from background. + private var pendingEvents: [[String: Any]] = [] + private let lock = NSLock() + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + lock.lock() self.eventSink = events + // Flush any buffered events + let buffered = pendingEvents + pendingEvents.removeAll() + lock.unlock() + + for event in buffered { + print("[Watch] flushing buffered event: \(event)") + events(event) + } return nil } func onCancel(withArguments arguments: Any?) -> FlutterError? { + lock.lock() self.eventSink = nil + lock.unlock() return nil } func send(_ event: [String: Any]) { - DispatchQueue.main.async { - self.eventSink?(event) + DispatchQueue.main.async { [self] in + lock.lock() + if let sink = eventSink { + lock.unlock() + print("[Watch] sending event to Flutter: \(event)") + sink(event) + } else { + // Buffer the event — Flutter hasn't connected yet + print("[Watch] buffering event (no sink): \(event)") + pendingEvents.append(event) + // Cap buffer to prevent unbounded growth + if pendingEvents.count > 50 { + pendingEvents.removeFirst() + } + lock.unlock() + } } } } diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..184b123 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..e2dec92 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..1091153 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..0c4000f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..3e1ed49 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..c0d724b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..9bf681e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..1091153 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..2d29052 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..cc1d69e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..cc1d69e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..25adb45 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..18a50f5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..af9be1f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..ef5d410 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Dark.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..68dd6f3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..ed70533 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..e1963b6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..4d27c1e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..b077ee8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..7b9d7b8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..9f05014 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..e1963b6 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..4d288ee Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..24f0668 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..24f0668 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..a807880 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..127ff9e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8cc6ec1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..bf5808d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-Tinted.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index c2ee7a7..3249a1f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index d376daa..a6afe7f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 4d1ae06..d86a1af 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index b9b6bc0..897daed 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 80eabad..ab0078f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 1b3a65c..6d6aa4f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 1a3c20f..818db07 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 4d1ae06..d86a1af 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 30a7646..24da8ea 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index a5c2b3e..63d538b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index a5c2b3e..63d538b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index fef1da4..498d599 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index c0f2d73..c563e4c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index d445693..f05be05 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 64c5b02..6a2b612 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 418f6e6..d34add6 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -47,6 +47,12 @@ Clawfree uses the microphone for hands-free AI agent interaction. NSSpeechRecognitionUsageDescription Clawfree uses speech recognition to process your voice commands. + NSLocalNetworkUsageDescription + Clawfree uses the local network to sync chat sessions between your iPhone, iPad, and Mac. + NSBonjourServices + + _clawfree._tcp + CADisableMinimumFrameDurationOnPhone UIApplicationSupportsIndirectInputEvents diff --git a/ios/WatchCompanion/AgentConfigView.swift b/ios/WatchCompanion/AgentConfigView.swift new file mode 100644 index 0000000..8662249 --- /dev/null +++ b/ios/WatchCompanion/AgentConfigView.swift @@ -0,0 +1,243 @@ +import SwiftUI + +// MARK: - 建 Agent 互動流程(Watch 端) +// 步驟:選模型 → 選技能 → 確認建立 + +struct AgentConfigView: View { + @ObservedObject var connectivity: ConnectivityProvider + var onComplete: (String) -> Void // 完成後回傳指令文字 + var onCancel: () -> Void + + // 品牌色 + private let lobsterOrange = Color(red: 1.0, green: 0.42, blue: 0.21) + private let teal = Color(red: 0.0, green: 0.75, blue: 0.65) + private let darkBg = Color(red: 0.1, green: 0.1, blue: 0.1) + + // 步驟狀態 + @State private var step = 0 // 0=模型, 1=技能, 2=確認 + @State private var selectedModel = 0 + @State private var skills: [SkillItem] = [ + SkillItem(name: "Flight Search", emoji: "✈️", selected: true), + SkillItem(name: "Hotel Booking", emoji: "🏨", selected: true), + SkillItem(name: "Itinerary", emoji: "📋", selected: true), + SkillItem(name: "Weather", emoji: "🌤️", selected: false), + SkillItem(name: "Restaurant", emoji: "🍽️", selected: false), + ] + + private let models = [ + ("Opus 4.6", "🧠"), + ("Sonnet 4.5", "⚡"), + ("Gemini Pro", "💎"), + ] + + var body: some View { + ZStack { + darkBg.ignoresSafeArea() + + VStack(spacing: 6) { + progressBar + + switch step { + case 0: modelPickerStep + case 1: skillsStep + default: confirmStep + } + } + } + .onAppear { + syncState() + // TTS: "What model would you like to use?" + WatchTTSService.shared.speak("What model would you like to use?") + } + .onChange(of: step) { newStep in + syncState() + // TTS when entering skills step + if newStep == 1 { + WatchTTSService.shared.speak("What skills should this agent have?") + } + } + .onChange(of: selectedModel) { _ in syncState() } + } + + /// 同步 Watch UI 狀態到 iPhone → iPad/macOS + private func syncState() { + let selectedSkills = skills.filter(\.selected).map(\.name) + connectivity.sendUIState([ + "flow": "agentConfig", + "step": step, + "selectedModel": models[selectedModel].0, + "selectedModelEmoji": models[selectedModel].1, + "selectedSkills": selectedSkills, + ]) + } + + // MARK: - 進度條 + private var progressBar: some View { + HStack(spacing: 4) { + ForEach(0..<3) { i in + RoundedRectangle(cornerRadius: 2) + .fill(i <= step ? lobsterOrange : Color.gray.opacity(0.3)) + .frame(height: 3) + } + } + .padding(.horizontal, 16) + .padding(.top, 4) + } + + // MARK: - Step 0: Select Model + private var modelPickerStep: some View { + VStack(spacing: 8) { + Text("Select Model") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + + // 模型卡片滑選 + TabView(selection: $selectedModel) { + ForEach(0..CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - WatchCompanion + Clawfree CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -20,16 +20,13 @@ 1.0 CFBundleVersion 1 - WKWatchKitApp - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - WKApplication WKCompanionAppBundleIdentifier art.dart.clawfree + NSMicrophoneUsageDescription + Clawfree needs the microphone for voice commands to your AI agent. + NSSpeechRecognitionUsageDescription + Clawfree uses speech recognition to convert your voice to text commands. diff --git a/ios/WatchCompanion/PulseMonitorView.swift b/ios/WatchCompanion/PulseMonitorView.swift index f6365d7..8cfc8aa 100644 --- a/ios/WatchCompanion/PulseMonitorView.swift +++ b/ios/WatchCompanion/PulseMonitorView.swift @@ -1,72 +1,554 @@ import SwiftUI +// MARK: - Main Watch View — One‑tap voice‑first UX + struct PulseMonitorView: View { @StateObject private var connectivity = ConnectivityProvider() - @State private var pulseAmount: CGFloat = 1.0 + @StateObject private var tts = WatchTTSService.shared + + /// 當前顯示的互動流程 + @State private var activeFlow: InteractiveFlow = .none + + enum InteractiveFlow { + case none // 主畫面(語音) + case agentConfig // 建 Agent 流程 + case tripPlanner // 規劃旅行流程 + } + + /// Current UI phase + @State private var phase: VoicePhase = .idle + /// Pulsing animation scale for the mic ring + @State private var pulseScale: CGFloat = 1.0 + /// Recording ring animation + @State private var recordingPulse: CGFloat = 1.0 + /// Dictation result (set by TextField sheet) + @State private var dictatedText: String = "" + /// Show the dictation sheet + @State private var showDictation = false + + // MARK: - Demo Mode + /// Demo mode flag (hard-coded for now, can be changed to env var later) + private let isDemoMode = true + /// Auto-demo: automatically play all 3 demo scripts on launch (for recording) + private let isAutoDemoMode = true + /// Current demo script index + @State private var demoScriptIndex = 0 + /// Auto-demo completed count + @State private var autoDemoCompleted = 0 + /// Demo scripts + private let demoScripts = [ + "Create a trip planner agent", + "Plan a 3 day trip to Tokyo", + "Add a sushi making class on day 2" + ] + /// Recognized text for demo mode (typewriter effect) + @State private var recognizedText = "" + /// Is currently showing recognition animation + @State private var isRecognizing = false + /// Ripple animation scale for demo recording + @State private var rippleScale: CGFloat = 1.0 + + enum VoicePhase { + case idle // Big mic button + case recording // Listening… (dictation active) + case sending // Sending… + case reply // AI answered + } - private var pulseDuration: Double { - switch connectivity.healthLevel { - case "nominal": return 1.2 - case "degraded": return 0.6 - case "error": return 0.3 - default: return 1.5 + // MARK: - Brand colours + private let lobsterOrange = Color(red: 1.0, green: 0.42, blue: 0.21) // #FF6B35 + private let teal = Color(red: 0.0, green: 0.75, blue: 0.65) // #00BFA5 + private let darkBg = Color(red: 0.1, green: 0.1, blue: 0.1) // #1A1A1A + + private var accentColor: Color { + switch phase { + case .idle: return teal + case .recording: return .red + case .sending: return lobsterOrange + case .reply: return teal } } + // MARK: - Body + var body: some View { + ZStack { + darkBg.ignoresSafeArea() + + // 互動流程覆蓋主畫面 + switch activeFlow { + case .agentConfig: + AgentConfigView( + connectivity: connectivity, + onComplete: { command in + connectivity.sendVoiceCommand(command) + withAnimation { activeFlow = .none; phase = .sending } + }, + onCancel: { withAnimation { activeFlow = .none } } + ) + case .tripPlanner: + TripPlannerView( + connectivity: connectivity, + onComplete: { command in + connectivity.sendVoiceCommand(command) + withAnimation { activeFlow = .none; phase = .sending } + }, + onCancel: { withAnimation { activeFlow = .none } } + ) + case .none: + mainVoiceView + } + } + .sheet(isPresented: $showDictation) { + DictationSheet(text: $dictatedText, onDone: handleDictationDone) + } + .onAppear { + startIdlePulse() + // Auto-demo: start playing after 2s delay + if isAutoDemoMode && isDemoMode { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + demoScriptIndex = 0 + playDemoAnimation() + } + } + } + .onChange(of: connectivity.lastAiReply) { newReply in + if newReply != nil && phase == .sending { + withAnimation(.easeInOut(duration: 0.3)) { phase = .reply } + if let r = newReply { + WatchTTSService.shared.speak(r) + } + } + } + } + + // MARK: - 主語音畫面 + private var mainVoiceView: some View { VStack(spacing: 8) { - // Agent Count Header - Text("\(connectivity.activeAgentCount) Agent\(connectivity.activeAgentCount == 1 ? "" : "s") Active") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.secondary) + // Branding bar + HStack(spacing: 4) { + Image(systemName: "hand.raised.slash.fill") + .font(.system(size: 10)) + .foregroundColor(lobsterOrange) + Text("Clawfree") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(lobsterOrange) - Spacer() + Spacer() - // Heartbeat Ring - ZStack { - // Outer Pulse - Circle() - .stroke(connectivity.healthColor.opacity(0.3), lineWidth: 2) - .scaleEffect(pulseAmount) - .opacity(2.0 - pulseAmount) + if connectivity.isPhoneActive { + Image(systemName: "iphone") + .font(.system(size: 9)) + .foregroundColor(.green) + } + } + .padding(.horizontal, 12) - // Main Ring - Circle() - .stroke(connectivity.healthColor, lineWidth: 6) - .frame(width: 80, height: 80) - - // Mic Icon - VStack(spacing: 2) { - Image(systemName: "mic.fill") - .font(.system(size: 24)) - .foregroundColor(connectivity.healthColor) - - Text(connectivity.isListening ? "Listening" : "Speak") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(connectivity.healthColor) + Spacer() + + // ── Central mic button (60%+ of screen) ── + ZStack { + // Outer pulse ring + Circle() + .stroke(accentColor.opacity(0.25), lineWidth: 3) + .scaleEffect(pulseScale) + .opacity(Double(2.0 - pulseScale)) + + // Recording pulse ring (only when recording) + if phase == .recording { + Circle() + .stroke(Color.red.opacity(0.5), lineWidth: 4) + .scaleEffect(recordingPulse) + .opacity(Double(2.0 - recordingPulse)) + } + + // Demo mode ripple animation (orange) + if isDemoMode && phase == .recording { + Circle() + .stroke(lobsterOrange.opacity(0.5), lineWidth: 5) + .scaleEffect(rippleScale) + .opacity(Double(2.0 - rippleScale)) + } + + // Main circle + Circle() + .fill(accentColor.opacity(0.15)) + + Circle() + .stroke(accentColor, lineWidth: 4) + + // Icon / state + micIcon } - } - .frame(width: 100, height: 100) - .onChange(of: connectivity.healthLevel) { _ in - pulseAmount = 1.0 - withAnimation(Animation.easeInOut(duration: pulseDuration).repeatForever(autoreverses: true)) { - pulseAmount = 1.2 + .frame(width: 110, height: 110) + .contentShape(Circle()) + .onTapGesture { handleTap() } + .accessibilityIdentifier("micButton") + + // Status label + statusText + .font(.system(size: 12, weight: .medium)) + .foregroundColor(accentColor) + .multilineTextAlignment(.center) + + // Demo mode recognized text (typewriter effect) + if isDemoMode && !recognizedText.isEmpty { + Text(recognizedText) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(teal) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(teal.opacity(0.15)) + ) + .padding(.horizontal, 8) } - } - .onAppear { - withAnimation(Animation.easeInOut(duration: pulseDuration).repeatForever(autoreverses: true)) { - pulseAmount = 1.2 + + Spacer() + + // AI reply bubble (compact) + if phase == .reply, let reply = connectivity.lastAiReply { + replyBubble(reply) + } + + // Connection status + HStack(spacing: 4) { + Circle() + .fill(connectivity.isReachable ? Color.green : Color.gray) + .frame(width: 6, height: 6) + Text(connectivity.isReachable ? "Connected" : "Offline") + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + .padding(.bottom, 2) + + // 快捷操作按鈕(idle 或 reply 時顯示) + if phase == .idle || phase == .reply { + HStack(spacing: 6) { + Button(action: { + if isDemoMode { + // Demo mode: trigger "Create Agent" script + demoScriptIndex = 0 + playDemoAnimation() + } else { + withAnimation { activeFlow = .agentConfig } + } + }) { + HStack(spacing: 2) { + Image(systemName: "cpu") + .font(.system(size: 8)) + Text("Create Agent") + .font(.system(size: 9)) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + } + .buttonStyle(.bordered) + .tint(lobsterOrange) + .accessibilityIdentifier("createAgentButton") + + Button(action: { + if isDemoMode { + // Demo mode: trigger "Plan Trip" script + demoScriptIndex = 1 + playDemoAnimation() + } else { + withAnimation { activeFlow = .tripPlanner } + } + }) { + HStack(spacing: 2) { + Image(systemName: "airplane") + .font(.system(size: 8)) + Text("Plan Trip") + .font(.system(size: 9)) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + } + .buttonStyle(.bordered) + .tint(teal) + .accessibilityIdentifier("planTripButton") + } + .padding(.bottom, 2) } } + } + - Spacer() + // MARK: - Sub‑views - // Status Label - Text(connectivity.statusLabel) + @ViewBuilder + private var micIcon: some View { + switch phase { + case .idle: + Image(systemName: "mic.fill") + .font(.system(size: 36, weight: .medium)) + .foregroundColor(accentColor) + case .recording: + Image(systemName: "waveform") + .font(.system(size: 32, weight: .medium)) + .foregroundColor(.red) + case .sending: + ProgressView() + .progressViewStyle(.circular) + .tint(lobsterOrange) + case .reply: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 32)) + .foregroundColor(teal) + } + } + + @ViewBuilder + private var statusText: some View { + switch phase { + case .idle: + Text("Tap to speak") + case .recording: + Text("Listening…") + case .sending: + Text("Sending…") + case .reply: + Text("Tap mic to continue") + } + } + + @ViewBuilder + private func replyBubble(_ reply: String) -> some View { + ScrollView { + Text(reply) .font(.system(size: 11)) - .foregroundColor(connectivity.healthColor) + .foregroundColor(.white) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 60) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(teal.opacity(0.15)) + ) + .padding(.horizontal, 8) + .onTapGesture { + WatchTTSService.shared.speak(reply) + } + } + + // MARK: - Actions + + private func handleTap() { + switch phase { + case .idle, .reply: + tts.stop() + + if isDemoMode { + // Demo mode: play animation instead of real dictation + playDemoAnimation() + } else { + // Normal mode: open dictation sheet + withAnimation(.easeInOut(duration: 0.2)) { phase = .recording } + startRecordingPulse() + dictatedText = "" + showDictation = true + } + case .recording: + if !isDemoMode { + // Normal mode: tapping again while recording → cancel + showDictation = false + withAnimation { phase = .idle } + } + // Demo mode: ignore tap while animating + case .sending: + break // ignore taps while sending + } + } + + private func handleDictationDone() { + showDictation = false + let trimmed = dictatedText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + withAnimation { phase = .idle } + return + } + let lower = trimmed.lowercased() + + // 語音觸發互動流程 + if lower.contains("create") && lower.contains("agent") { + withAnimation { phase = .idle; activeFlow = .agentConfig } + return + } + if lower.contains("plan") && (lower.contains("trip") || lower.contains("travel")) { + withAnimation { phase = .idle; activeFlow = .tripPlanner } + return + } + + // 一般語音指令 → 直接發送到 iPhone + withAnimation(.easeInOut(duration: 0.2)) { phase = .sending } + connectivity.sendVoiceCommand(trimmed) + } + + // MARK: - Demo Mode Animation + + private func playDemoAnimation() { + guard demoScriptIndex < demoScripts.count else { + // Reset to first script + demoScriptIndex = 0 + playDemoAnimation() + return + } + + let script = demoScripts[demoScriptIndex] + + // Step 1: Start recording animation (1-2 seconds) + withAnimation(.easeInOut(duration: 0.2)) { + phase = .recording + recognizedText = "" + isRecognizing = false + } + startRecordingPulse() + startRipplePulse() + + // Step 2: After 1.5s, start typewriter effect + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + isRecognizing = true + } + typewriterEffect(text: script) + } + } + + private func typewriterEffect(text: String) { + recognizedText = "" + let characters = Array(text) + var currentIndex = 0 + + // Type each character with 0.05s delay + Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in + if currentIndex < characters.count { + recognizedText.append(characters[currentIndex]) + currentIndex += 1 + } else { + timer.invalidate() + // Step 3: Text complete, send command + sendDemoCommand() + } + } + } + + private func sendDemoCommand() { + let command = recognizedText + + // Flash text (blink effect) + withAnimation(.easeInOut(duration: 0.2)) { + recognizedText = "Sent ✓" + } + + // Step 4: Send command via connectivity + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation(.easeInOut(duration: 0.2)) { + phase = .sending + } + connectivity.sendVoiceCommand(command) + + // Step 5: After 0.8s, reset to idle + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + withAnimation(.easeInOut(duration: 0.3)) { + phase = .idle + recognizedText = "" + isRecognizing = false + } + + // Move to next script + demoScriptIndex = (demoScriptIndex + 1) % demoScripts.count + autoDemoCompleted += 1 + + // Auto-demo: chain next script after 2s pause + if isAutoDemoMode && autoDemoCompleted < demoScripts.count { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + playDemoAnimation() + } + } + } + } + } + + // MARK: - Animations + + private func startIdlePulse() { + pulseScale = 1.0 + withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { + pulseScale = 1.25 + } + } + + private func startRecordingPulse() { + recordingPulse = 1.0 + withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) { + recordingPulse = 1.4 + } + } + + private func startRipplePulse() { + rippleScale = 1.0 + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: false)) { + rippleScale = 1.6 + } + } +} + +// MARK: - Dictation Sheet (minimal — auto‑focus TextField triggers watchOS dictation) + +struct DictationSheet: View { + @Binding var text: String + var onDone: () -> Void + @FocusState private var focused: Bool + + var body: some View { + VStack(spacing: 12) { + // Recording indicator + ZStack { + Circle() + .fill(Color.red.opacity(0.2)) + .frame(width: 50, height: 50) + Image(systemName: "mic.fill") + .font(.system(size: 22)) + .foregroundColor(.red) + } + + Text("Speak now") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white) + + // Hidden TextField — triggers system dictation + TextField("", text: $text) + .focused($focused) + .font(.system(size: 14)) + .multilineTextAlignment(.center) + .onSubmit { onDone() } + + // Send button (for manual submit after dictation) + Button(action: onDone) { + HStack(spacing: 4) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 16)) + Text("Send") + .font(.system(size: 14, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + } + .buttonStyle(.borderedProminent) + .tint(Color(red: 1.0, green: 0.42, blue: 0.21)) + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding() + .onAppear { + // Auto-trigger dictation keyboard + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + focused = true + } + } } } diff --git a/ios/WatchCompanion/SpeechRecognizer.swift b/ios/WatchCompanion/SpeechRecognizer.swift new file mode 100644 index 0000000..4e4e884 --- /dev/null +++ b/ios/WatchCompanion/SpeechRecognizer.swift @@ -0,0 +1,36 @@ +import Foundation +import SwiftUI + +/// Voice input handler for watchOS. +/// +/// On watchOS, Speech framework is not available. Instead we use SwiftUI's +/// built-in dictation support via `.searchable` or text field with dictation. +/// This class manages the state for a simple text-input-based flow that +/// uses watchOS dictation (the system mic button on TextField). +class SpeechRecognizer: ObservableObject { + @Published var transcript: String = "" + @Published var isRecording: Bool = false + @Published var errorMessage: String? + + /// Called when the user submits dictated/typed text. + func submit(_ text: String) { + transcript = text + isRecording = false + } + + /// Simulates starting a "recording" session (shows dictation UI). + func startRecording() { + isRecording = true + transcript = "" + errorMessage = nil + } + + func stopRecording() { + isRecording = false + } + + func requestAuthorization(completion: @escaping (Bool) -> Void) { + // No explicit authorization needed for watchOS dictation + completion(true) + } +} diff --git a/ios/WatchCompanion/TripPlannerView.swift b/ios/WatchCompanion/TripPlannerView.swift new file mode 100644 index 0000000..7e56acd --- /dev/null +++ b/ios/WatchCompanion/TripPlannerView.swift @@ -0,0 +1,323 @@ +import SwiftUI + +// MARK: - 使用 Agent 規劃旅行(Watch 端) +// 步驟:選目的地 → 選天數 → 選景點 → 送出 + +struct TripPlannerView: View { + @ObservedObject var connectivity: ConnectivityProvider + var onComplete: (String) -> Void + var onCancel: () -> Void + + private let lobsterOrange = Color(red: 1.0, green: 0.42, blue: 0.21) + private let teal = Color(red: 0.0, green: 0.75, blue: 0.65) + private let darkBg = Color(red: 0.1, green: 0.1, blue: 0.1) + + @State private var step = 0 // 0=目的地, 1=天數, 2=景點, 3=確認 + @State private var selectedCity = 0 + @State private var selectedDays = 1 // index into daysOptions + @State private var attractions: [AttractionItem] = [] + + private let cities = [ + CityCard(name: "Tokyo", emoji: "🗼", flag: "🇯🇵", color: Color.pink), + CityCard(name: "Kyoto", emoji: "⛩️", flag: "🇯🇵", color: Color.orange), + CityCard(name: "Osaka", emoji: "🏯", flag: "🇯🇵", color: Color.purple), + CityCard(name: "Seoul", emoji: "🏙️", flag: "🇰🇷", color: Color.blue), + CityCard(name: "Bangkok", emoji: "🛕", flag: "🇹🇭", color: Color.yellow), + ] + + private let daysOptions = [3, 5, 7] + + // 各城市景點 + private let cityAttractions: [[AttractionItem]] = [ + // Tokyo + [ + AttractionItem(name: "Senso-ji Temple", emoji: "⛩️", selected: true), + AttractionItem(name: "Akihabara", emoji: "🎮", selected: true), + AttractionItem(name: "Shibuya Crossing", emoji: "🚶", selected: false), + AttractionItem(name: "Tsukiji Market", emoji: "🍣", selected: true), + AttractionItem(name: "Mt. Fuji Day Trip", emoji: "🗻", selected: false), + ], + // Kyoto + [ + AttractionItem(name: "Fushimi Inari", emoji: "⛩️", selected: true), + AttractionItem(name: "Bamboo Grove", emoji: "🎋", selected: true), + AttractionItem(name: "Golden Pavilion", emoji: "🏛️", selected: true), + AttractionItem(name: "Geisha District", emoji: "🎭", selected: false), + ], + // Osaka + [ + AttractionItem(name: "Osaka Castle", emoji: "🏯", selected: true), + AttractionItem(name: "Dotonbori", emoji: "🏮", selected: true), + AttractionItem(name: "Universal Studios", emoji: "🎢", selected: false), + AttractionItem(name: "Street Food Tour", emoji: "🍢", selected: true), + ], + // Seoul + [ + AttractionItem(name: "Gyeongbokgung", emoji: "🏛️", selected: true), + AttractionItem(name: "Myeongdong", emoji: "🛍️", selected: true), + AttractionItem(name: "N Seoul Tower", emoji: "🗼", selected: false), + AttractionItem(name: "Korean BBQ Tour", emoji: "🥩", selected: true), + ], + // Bangkok + [ + AttractionItem(name: "Grand Palace", emoji: "👑", selected: true), + AttractionItem(name: "Floating Market", emoji: "🛶", selected: true), + AttractionItem(name: "Chatuchak Market", emoji: "🏪", selected: false), + AttractionItem(name: "Thai Cooking Class", emoji: "🍜", selected: true), + ], + ] + + var body: some View { + ZStack { + darkBg.ignoresSafeArea() + + VStack(spacing: 6) { + progressBar + + switch step { + case 0: cityStep + case 1: daysStep + case 2: attractionsStep + default: summaryStep + } + } + } + .onAppear { + attractions = cityAttractions[selectedCity] + syncState() + // TTS: "Where would you like to travel?" + WatchTTSService.shared.speak("Where would you like to travel?") + } + .onChange(of: selectedCity) { newVal in + if newVal < cityAttractions.count { + attractions = cityAttractions[newVal] + } + syncState() + } + .onChange(of: step) { newStep in + syncState() + // TTS when entering days step + if newStep == 1 { + WatchTTSService.shared.speak("How many days?") + } + } + .onChange(of: selectedDays) { _ in syncState() } + } + + // MARK: - 進度條 + private var progressBar: some View { + HStack(spacing: 3) { + ForEach(0..<4) { i in + RoundedRectangle(cornerRadius: 2) + .fill(i <= step ? teal : Color.gray.opacity(0.3)) + .frame(height: 3) + } + } + .padding(.horizontal, 16) + .padding(.top, 4) + } + + /// 同步 Watch UI 狀態到 iPhone → iPad/macOS + private func syncState() { + let selectedAttr = attractions.filter(\.selected).map(\.name) + connectivity.sendUIState([ + "flow": "tripPlanner", + "step": step, + "selectedCity": cities[selectedCity].name, + "selectedCityEmoji": cities[selectedCity].emoji, + "selectedDays": daysOptions[selectedDays], + "selectedAttractions": selectedAttr, + ]) + } + + // MARK: - Step 0: Select Destination + private var cityStep: some View { + VStack(spacing: 6) { + Text("🌍 Where to?") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + + TabView(selection: $selectedCity) { + ForEach(0..= 0x4E00 && $0.value <= 0x9FFF }.count + let ratio = text.isEmpty ? 0.0 : Double(cjkCount) / Double(text.count) + utterance.voice = AVSpeechSynthesisVoice(language: ratio > 0.1 ? "zh-TW" : "en-US") + + utterance.rate = AVSpeechUtteranceDefaultSpeechRate + utterance.pitchMultiplier = 1.0 + utterance.volume = 1.0 + + isSpeaking = true + synthesizer.speak(utterance) + } + + /// Stop speaking immediately. + func stop() { + synthesizer.stopSpeaking(at: .immediate) + isSpeaking = false + } + + // MARK: - AVSpeechSynthesizerDelegate + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + DispatchQueue.main.async { + self.isSpeaking = false + } + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + DispatchQueue.main.async { + self.isSpeaking = false + } + } +} diff --git a/ios/WatchCompanionUITests/Info.plist b/ios/WatchCompanionUITests/Info.plist new file mode 100644 index 0000000..6c40a6c --- /dev/null +++ b/ios/WatchCompanionUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/ios/WatchCompanionUITests/WatchDemoFlowTest.swift b/ios/WatchCompanionUITests/WatchDemoFlowTest.swift new file mode 100644 index 0000000..5abdbd5 --- /dev/null +++ b/ios/WatchCompanionUITests/WatchDemoFlowTest.swift @@ -0,0 +1,52 @@ +import XCTest + +final class WatchDemoFlowTest: XCTestCase { + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + override func tearDownWithError() throws { + app = nil + } + + func testDemoFlow() throws { + // Wait for app to fully load + sleep(2) + + // Step 1: Tap mic button to trigger first demo animation + // This should play "Create a trip planner agent" demo + let micButton = app.buttons["micButton"] + XCTAssertTrue(micButton.waitForExistence(timeout: 5), "Mic button should exist") + micButton.tap() + + // Wait for demo animation to complete + // Animation sequence: recording (1.5s) + typewriter (~2s) + send (0.8s) = ~4.5s + sleep(5) + + // Step 2: Tap "Create Agent" quick action + let createAgentButton = app.buttons["createAgentButton"] + XCTAssertTrue(createAgentButton.waitForExistence(timeout: 5), "Create Agent button should exist") + createAgentButton.tap() + + // Wait for animation complete + sleep(5) + + // Step 3: Tap "Plan Trip" quick action + let planTripButton = app.buttons["planTripButton"] + XCTAssertTrue(planTripButton.waitForExistence(timeout: 5), "Plan Trip button should exist") + planTripButton.tap() + + // Wait for final animation complete + sleep(5) + + // Verify we're back to idle state (mic button should be tappable) + XCTAssertTrue(micButton.exists, "Should return to main view with mic button") + + // Success - demo flow completed + print("✅ Demo flow completed successfully") + } +} diff --git a/lib/main.dart b/lib/main.dart index e853e3d..e4c396e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,6 +30,7 @@ import 'src/voice/voice_service_factory.dart'; const _apiKey = String.fromEnvironment('ANTHROPIC_API_KEY', defaultValue: ''); const _gatewayUrl = String.fromEnvironment('GATEWAY_URL', defaultValue: ''); const _demoMode = bool.fromEnvironment('DEMO_MODE', defaultValue: false); +const _autoDemo = bool.fromEnvironment('AUTO_DEMO', defaultValue: false); void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -73,6 +74,7 @@ class ClawfreeApp extends StatelessWidget { title: 'clawfree', theme: ClawfreeTheme.light, darkTheme: ClawfreeTheme.dark, + themeMode: ThemeMode.dark, themeAnimationDuration: const Duration(milliseconds: 400), themeAnimationCurve: Curves.easeInOut, home: const ClawfreeHome(), @@ -100,25 +102,54 @@ class _ClawfreeHomeState extends State { void initState() { super.initState(); _initDeepLinks(); + // Auto-start in demo mode when built with DEMO_MODE=true + if (_demoMode) { + WidgetsBinding.instance.addPostFrameCallback((_) => _start()); + } } void _initDeepLinks() { _appLinks = AppLinks(); // Handle cold-start deep link (app launched via URL). _appLinks.getInitialLink().then((uri) { - if (uri != null && uri.scheme == 'clawfree' && uri.host == 'pair') { + if (uri != null && uri.scheme == 'clawfree') { _handleDeepLink(uri); } }); // Handle warm-start deep links (app already running). _linkSubscription = _appLinks.uriLinkStream.listen((uri) { - if (uri.scheme == 'clawfree' && uri.host == 'pair') { + if (uri.scheme == 'clawfree') { _handleDeepLink(uri); } }); } + Future _runAutoDemo() async { + final session = _chatSession; + if (session == null) return; + const demoMessages = [ + 'Create a trip planner agent', + 'Plan a 3 day trip to Tokyo', + 'Add a sushi making class on day 2', + ]; + for (final msg in demoMessages) { + await Future.delayed(const Duration(seconds: 5)); + if (_chatSession == null) return; // disposed + session.sendMessage(msg); + await Future.delayed(const Duration(seconds: 4)); + } + } + void _handleDeepLink(Uri uri) { + // Demo input: clawfree://demo?text=hello + if (uri.host == 'demo' && uri.queryParameters.containsKey('text')) { + final text = uri.queryParameters['text']!; + if (_chatSession != null) { + _chatSession!.sendMessage(text); + } + return; + } + final pairing = PlatformConfig.parsePairingUri(uri); if (pairing == null) return; @@ -212,6 +243,12 @@ class _ClawfreeHomeState extends State { _sttService = sl.tryGet(); if (!mounted) return; + + // Auto-demo: send a sequence of demo messages with delays + if (_autoDemo && _chatSession != null) { + _runAutoDemo(); + } + Navigator.of(context).push( MaterialPageRoute( builder: (_) => ChatScreen( diff --git a/lib/src/core/demo_ai_client.dart b/lib/src/core/demo_ai_client.dart index 29d05e9..76b79ba 100644 --- a/lib/src/core/demo_ai_client.dart +++ b/lib/src/core/demo_ai_client.dart @@ -69,6 +69,16 @@ class DemoCacheAiClient implements AiClient { /// Order matters: entries are checked in insertion order via [contains], /// so refinement keywords come before broad creation keywords. static final Map defaultResponses = { + // Trip Planning demo (check before generic travel/add) + 'sushi': _sushiClassResponse, + 'weather in tokyo': _tokyoWeatherResponse, + 'create trip': _createTripAgentResponse, + 'trip planner': _createTripAgentResponse, + 'trip agent': _createTripAgentResponse, + 'plan a 3': _tripTokyoItineraryResponse, + 'plan 3': _tripTokyoItineraryResponse, + '3 day trip': _tripTokyoItineraryResponse, + '3-day': _tripTokyoItineraryResponse, // Refinement (check before broad keywords) 'rename': _renameAgentResponse, 'add': _addToolResponse, @@ -868,5 +878,120 @@ class DemoCacheAiClient implements AiClient { {"id": "persona-switch", "component": "ChoicePicker", "label": "Switch Persona", "variant": "mutuallyExclusive", "options": [{"label": "Foodie", "value": "foodie"}, {"label": "Artsy", "value": "artsy"}, {"label": "Outdoorsy", "value": "outdoorsy"}], "value": ["outdoorsy"], "onSubmit": {"event": {"name": "generate_itinerary", "context": {"city": "tokyo", "persona": {"path": "persona-switch.value"}, "days": "3"}}}} ]}} ] +```'''; + + // --------------------------------------------------------------------------- + // Trip Planning Demo: Agent creation + // --------------------------------------------------------------------------- + + static const _createTripAgentResponse = + '''Great! I'll set up a Trip Planner agent powered by Opus 4.6 with flight search, hotel booking, and itinerary planning skills. + +```json +[ + {"version": "v0.9", "createSurface": {"surfaceId": "trip-agent-001", "catalogId": "$_catalogId"}}, + {"version": "v0.9", "updateComponents": {"surfaceId": "trip-agent-001", "components": [ + {"id": "root", "component": "Column", "children": ["title", "desc", "name-field", "model-picker", "skills-picker", "create-btn"]}, + {"id": "title", "component": "Text", "text": "Create Trip Planner Agent", "variant": "h4"}, + {"id": "desc", "component": "Text", "text": "Configure your AI travel assistant with specialized planning skills."}, + {"id": "name-field", "component": "TextField", "label": "Agent Name", "text": "Trip Planner"}, + {"id": "model-picker", "component": "ChoicePicker", "label": "AI Model", "variant": "mutuallyExclusive", "options": [{"label": "Claude Opus 4.6", "value": "claude-opus-4-6"}, {"label": "Claude Sonnet 4.5", "value": "claude-sonnet-4-5"}], "value": ["claude-opus-4-6"]}, + {"id": "skills-picker", "component": "ChoicePicker", "label": "Skills", "variant": "multipleSelection", "options": [{"label": "Flight Search", "value": "flights"}, {"label": "Hotel Booking", "value": "hotels"}, {"label": "Itinerary Planning", "value": "itinerary"}, {"label": "Weather Lookup", "value": "weather"}, {"label": "Restaurant Finder", "value": "restaurants"}], "value": ["flights", "hotels", "itinerary"]}, + {"id": "create-btn", "component": "Button", "child": "create-btn-text", "variant": "primary", "action": {"event": {"name": "save_agent", "context": {"name": {"path": "name-field.value"}, "model": {"path": "model-picker.value"}, "skills": {"path": "skills-picker.value"}}}}}, + {"id": "create-btn-text", "component": "Text", "text": "Create Agent"} + ]}} +] +```'''; + + // --------------------------------------------------------------------------- + // Trip Planning Demo: 3-day Tokyo itinerary + // --------------------------------------------------------------------------- + + static const _tripTokyoItineraryResponse = + '''Here's your 3-day Tokyo itinerary! I've planned a mix of culture, food, and exploration with an estimated budget of \u00a5150,000 (~\$1,000). + +```json +[ + {"version": "v0.9", "createSurface": {"surfaceId": "trip-itin-001", "catalogId": "$_catalogId"}}, + {"version": "v0.9", "updateComponents": {"surfaceId": "trip-itin-001", "components": [ + {"id": "root", "component": "Column", "children": ["title", "budget-card", "day1-card", "day2-card", "day3-card"]}, + {"id": "title", "component": "Text", "text": "Tokyo \u2014 3 Day Itinerary", "variant": "h4"}, + {"id": "budget-card", "component": "Card", "child": "budget-col"}, + {"id": "budget-col", "component": "Column", "children": ["budget-title", "budget-detail"]}, + {"id": "budget-title", "component": "Text", "text": "Estimated Budget: \u00a5150,000", "variant": "h5"}, + {"id": "budget-detail", "component": "Text", "text": "Accommodation \u00a545,000 \u2022 Food \u00a530,000 \u2022 Transport \u00a515,000 \u2022 Activities \u00a520,000 \u2022 Shopping \u00a540,000"}, + {"id": "day1-card", "component": "Card", "child": "day1-col"}, + {"id": "day1-col", "component": "Column", "children": ["day1-title", "day1-am", "day1-mid", "day1-pm", "day1-dinner"]}, + {"id": "day1-title", "component": "Text", "text": "Day 1 \u2014 Shibuya & Harajuku", "variant": "h5"}, + {"id": "day1-am", "component": "Text", "text": "\ud83c\udf05 Morning: Meiji Shrine \u2014 serene forest walk in the heart of Tokyo"}, + {"id": "day1-mid", "component": "Text", "text": "\ud83d\udecd Midday: Takeshita Street \u2014 Harajuku fashion, crepes, and street culture"}, + {"id": "day1-pm", "component": "Text", "text": "\ud83c\udf06 Afternoon: Shibuya Crossing \u2014 world's busiest intersection, Hachiko statue"}, + {"id": "day1-dinner", "component": "Text", "text": "\ud83c\udf5c Dinner: Fuunji Ramen \u2014 famous tsukemen near Shinjuku Station"}, + {"id": "day2-card", "component": "Card", "child": "day2-col"}, + {"id": "day2-col", "component": "Column", "children": ["day2-title", "day2-am", "day2-mid", "day2-pm", "day2-dinner"]}, + {"id": "day2-title", "component": "Text", "text": "Day 2 \u2014 Asakusa & Akihabara", "variant": "h5"}, + {"id": "day2-am", "component": "Text", "text": "\u26e9 Morning: Senso-ji Temple \u2014 Tokyo's oldest temple, Thunder Gate"}, + {"id": "day2-mid", "component": "Text", "text": "\ud83c\udfed Midday: Nakamise Shopping Street \u2014 traditional snacks and souvenirs"}, + {"id": "day2-pm", "component": "Text", "text": "\ud83d\udd0c Afternoon: Akihabara \u2014 electronics, anime, and gaming paradise"}, + {"id": "day2-dinner", "component": "Text", "text": "\ud83c\udf76 Dinner: Izakaya hopping \u2014 yakitori and sake under the rail tracks"}, + {"id": "day3-card", "component": "Card", "child": "day3-col"}, + {"id": "day3-col", "component": "Column", "children": ["day3-title", "day3-am", "day3-mid", "day3-pm", "day3-depart"]}, + {"id": "day3-title", "component": "Text", "text": "Day 3 \u2014 Shinjuku & Departure", "variant": "h5"}, + {"id": "day3-am", "component": "Text", "text": "\ud83c\udf38 Morning: Shinjuku Gyoen Park \u2014 beautiful gardens, perfect for a morning stroll"}, + {"id": "day3-mid", "component": "Text", "text": "\ud83d\uddfc Midday: Tokyo Tower \u2014 panoramic city views from the observation deck"}, + {"id": "day3-pm", "component": "Text", "text": "\ud83c\udf63 Afternoon: Tsukiji Outer Market \u2014 fresh sushi and street food for lunch"}, + {"id": "day3-depart", "component": "Text", "text": "\u2708\ufe0f Evening: Depart from Narita/Haneda \u2014 pick up last-minute souvenirs at the airport"} + ]}} +] +```'''; + + // --------------------------------------------------------------------------- + // Trip Planning Demo: Add sushi class to Day 2 + // --------------------------------------------------------------------------- + + static const _sushiClassResponse = + '''Done! I've added a Sushi Making Class to Day 2. The updated itinerary now includes the class between Nakamise and Akihabara. + +```json +[ + {"version": "v0.9", "updateComponents": {"surfaceId": "trip-itin-001", "components": [ + {"id": "day2-col", "component": "Column", "children": ["day2-title", "day2-am", "day2-mid", "day2-sushi", "day2-pm", "day2-dinner"]}, + {"id": "day2-title", "component": "Text", "text": "Day 2 \u2014 Asakusa & Akihabara (Updated)", "variant": "h5"}, + {"id": "day2-am", "component": "Text", "text": "\u26e9 Morning: Senso-ji Temple \u2014 Tokyo's oldest temple, Thunder Gate"}, + {"id": "day2-mid", "component": "Text", "text": "\ud83c\udfed Midday: Nakamise Shopping Street \u2014 traditional snacks and souvenirs"}, + {"id": "day2-sushi", "component": "Text", "text": "\ud83c\udf63 NEW: Sushi Making Class \u2014 Learn to make nigiri and maki from a master chef (2 hours, \u00a58,000)"}, + {"id": "day2-pm", "component": "Text", "text": "\ud83d\udd0c Afternoon: Akihabara \u2014 electronics, anime, and gaming paradise"}, + {"id": "day2-dinner", "component": "Text", "text": "\ud83c\udf76 Dinner: Izakaya hopping \u2014 yakitori and sake under the rail tracks"} + ]}} +] +```'''; + + // --------------------------------------------------------------------------- + // Trip Planning Demo: Tokyo weather + // --------------------------------------------------------------------------- + + static const _tokyoWeatherResponse = + '''Here's the current weather in Tokyo \u2014 great conditions for sightseeing! + +```json +[ + {"version": "v0.9", "createSurface": {"surfaceId": "weather-001", "catalogId": "$_catalogId"}}, + {"version": "v0.9", "updateComponents": {"surfaceId": "weather-001", "components": [ + {"id": "root", "component": "Column", "children": ["title", "weather-card", "forecast-card", "tip"]}, + {"id": "title", "component": "Text", "text": "Tokyo Weather", "variant": "h4"}, + {"id": "weather-card", "component": "Card", "child": "weather-col"}, + {"id": "weather-col", "component": "Column", "children": ["temp", "condition", "details"]}, + {"id": "temp", "component": "Text", "text": "15\u00b0C / 59\u00b0F", "variant": "h4"}, + {"id": "condition", "component": "Text", "text": "\u26c5 Partly Cloudy", "variant": "h6"}, + {"id": "details", "component": "Text", "text": "Humidity: 62% \u2022 Wind: 8 km/h NW \u2022 UV Index: 3 (Moderate)"}, + {"id": "forecast-card", "component": "Card", "child": "forecast-col"}, + {"id": "forecast-col", "component": "Column", "children": ["forecast-title", "fc-1", "fc-2", "fc-3"]}, + {"id": "forecast-title", "component": "Text", "text": "3-Day Forecast", "variant": "h6"}, + {"id": "fc-1", "component": "Text", "text": "Tomorrow: 17\u00b0C \u2600\ufe0f Sunny \u2014 Perfect for outdoor sightseeing"}, + {"id": "fc-2", "component": "Text", "text": "Day 3: 14\u00b0C \ud83c\udf27\ufe0f Light Rain \u2014 Bring an umbrella, great for museums"}, + {"id": "fc-3", "component": "Text", "text": "Day 4: 16\u00b0C \u26c5 Partly Cloudy \u2014 Good conditions overall"}, + {"id": "tip", "component": "Text", "text": "\ud83d\udca1 Tip: Layer up! Tokyo mornings can be cool but afternoons warm up. Perfect weather for walking tours.", "variant": "body2"} + ]}} +] ```'''; } diff --git a/lib/src/core/input_coordinator.dart b/lib/src/core/input_coordinator.dart new file mode 100644 index 0000000..74ff5d7 --- /dev/null +++ b/lib/src/core/input_coordinator.dart @@ -0,0 +1,89 @@ +import 'package:flutter/foundation.dart'; + +/// Source of a voice/text input. +enum InputSource { phone, watch } + +/// Current state of the AI interaction pipeline. +enum PipelineState { idle, recording, processing, responding } + +/// Tracks which device is actively using the AI pipeline and coordinates +/// overlapping requests from phone and watch. +/// +/// Policy: first-come-first-served. Late arrivals are queued (max 1 per source). +/// When the AI finishes responding, the queued item is auto-submitted. +class InputCoordinator extends ChangeNotifier { + PipelineState _state = PipelineState.idle; + InputSource? _activeSource; + _QueuedInput? _queuedInput; + + PipelineState get state => _state; + InputSource? get activeSource => _activeSource; + InputSource? get queuedSource => _queuedInput?.source; + + bool get isWatchActive => _activeSource == InputSource.watch; + bool get isPhoneActive => _activeSource == InputSource.phone; + bool get hasQueuedInput => _queuedInput != null; + + /// Attempt to claim the pipeline for recording from [source]. + /// Returns true if granted, false if pipeline is busy. + bool requestAccess(InputSource source) { + if (_state == PipelineState.idle) { + _activeSource = source; + _state = PipelineState.recording; + notifyListeners(); + return true; + } + return false; + } + + /// Submit text from [source]. If pipeline is idle or owned by this source, + /// returns the text for immediate send. Otherwise queues it and returns null. + String? submit(InputSource source, String text) { + if (_state == PipelineState.idle || _activeSource == source) { + _activeSource = source; + _state = PipelineState.processing; + notifyListeners(); + return text; + } + // Queue the input (replace any existing queue for this source) + _queuedInput = _QueuedInput(source: source, text: text); + notifyListeners(); + return null; + } + + /// Called when AI starts streaming a response. + void markResponding() { + if (_state == PipelineState.processing) { + _state = PipelineState.responding; + notifyListeners(); + } + } + + /// Called when AI response is fully complete. + /// Returns queued (source, text) if any, or null. + (InputSource, String)? markComplete() { + _state = PipelineState.idle; + _activeSource = null; + final queued = _queuedInput; + _queuedInput = null; + notifyListeners(); + if (queued != null) { + return (queued.source, queued.text); + } + return null; + } + + /// Cancel the current interaction. + void cancel() { + _state = PipelineState.idle; + _activeSource = null; + _queuedInput = null; + notifyListeners(); + } +} + +class _QueuedInput { + const _QueuedInput({required this.source, required this.text}); + final InputSource source; + final String text; +} diff --git a/lib/src/core/watch_bridge.dart b/lib/src/core/watch_bridge.dart index 3d7cd72..6d88078 100644 --- a/lib/src/core/watch_bridge.dart +++ b/lib/src/core/watch_bridge.dart @@ -1,56 +1,291 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; -/// A voice event received from the Apple Watch via WCSession file transfer. +/// A voice event received from the Apple Watch via WCSession or gateway relay. +/// +/// Supports both file-based transfers (legacy) and text-based voice commands +/// (speech recognized on Watch, sent as text). class WatchVoiceEvent { - WatchVoiceEvent({required this.filePath, required this.timestamp}); + WatchVoiceEvent({ + this.filePath, + this.text, + required this.timestamp, + required this.type, + this.uiState, + }); + + /// For file-based transfers: local path to the `.m4a` audio. + final String? filePath; + + /// For text-based voice commands: the recognized speech text. + final String? text; - final String filePath; final DateTime timestamp; + /// Event type: `"voice"` (file), `"voice_command"` (text), `"ai_reply"`, or `"ui_state"`. + final String type; + + /// Watch UI 狀態資料(flow, step, selections 等) + final Map? uiState; + + /// Whether this is a text-based voice command (speech recognized on Watch). + bool get isTextCommand => type == 'voice_command' && text != null; + + /// Whether this is a Watch UI state sync event. + bool get isUIState => type == 'ui_state'; + factory WatchVoiceEvent.fromMap(Map map) { + // 提取 ui_state 相關欄位 + Map? uiState; + if (map['type'] == 'ui_state') { + uiState = Map.from(map); + } return WatchVoiceEvent( - filePath: map['filePath'] as String, + filePath: map['filePath'] as String?, + text: map['text'] as String?, timestamp: map['timestamp'] != null - ? DateTime.fromMillisecondsSinceEpoch(map['timestamp'] as int) + ? DateTime.fromMillisecondsSinceEpoch( + (map['timestamp'] as num).toInt()) : DateTime.now(), + type: map['type'] as String? ?? 'voice', + uiState: uiState, ); } + + Map toJson() => { + if (filePath != null) 'filePath': filePath, + if (text != null) 'text': text, + 'timestamp': timestamp.millisecondsSinceEpoch, + 'type': type, + }; } /// Bidirectional bridge between Flutter and the Apple Watch. /// -/// Uses an [EventChannel] to receive voice file events pushed from native -/// (Watch -> iPhone -> Flutter) and a [MethodChannel] to send replies and -/// query reachability (Flutter -> Native -> Watch). +/// On iPhone: uses WCSession via platform channels (direct Watch connection). +/// On iPad/other: falls back to gateway SSE relay for Watch communication. class WatchBridge { WatchBridge._(); - static const _methodChannel = - MethodChannel('art.dart.clawfree/watch'); - static const _eventChannel = - EventChannel('art.dart.clawfree/watch_events'); + static const _methodChannel = MethodChannel('art.dart.clawfree/watch'); + static const _eventChannel = EventChannel('art.dart.clawfree/watch_events'); + + /// Gateway base URL for relay mode (set by ChatScreen on init). + static String? _gatewayBaseUrl; + + /// Whether we're using gateway relay (iPad) vs direct WCSession (iPhone). + static bool _useRelay = false; + + static StreamController? _relayController; + static http.Client? _sseClient; + + /// Initialize the bridge. Call once at app startup. + /// + /// On iPhone, [gatewayUrl] is used to also broadcast Watch events to the + /// relay so iPads can receive them. On iPad, it's used as the sole + /// communication channel. + static void configure({required String gatewayUrl}) { + _gatewayBaseUrl = gatewayUrl; + debugPrint('[WatchBridge] configure(gatewayUrl=$gatewayUrl)'); + + if (kIsWeb) return; + + if (Platform.isIOS) { + debugPrint('[WatchBridge] iOS detected, probing WCSession...'); + _checkDeviceAndStartRelay(); + } else if (Platform.isMacOS) { + debugPrint('[WatchBridge] macOS detected, using relay mode'); + _useRelay = true; + _startRelaySubscription(); + } + } + + static Future _checkDeviceAndStartRelay() async { + try { + final reachable = await _methodChannel.invokeMethod('isWatchReachable'); + debugPrint('[WatchBridge] WCSession available (iPhone), reachable=$reachable, using direct mode'); + _useRelay = false; + _startIPhoneRelayListener(); + } on MissingPluginException { + debugPrint('[WatchBridge] MissingPluginException — iPad or no WCSession, using relay'); + _useRelay = true; + _startRelaySubscription(); + } on PlatformException catch (e) { + debugPrint('[WatchBridge] PlatformException: $e — using relay'); + _useRelay = true; + _startRelaySubscription(); + } + } + + /// iPhone subscribes to relay to pick up iPad commands and forward to Watch. + static http.Client? _iphoneRelayClient; + + static void _startIPhoneRelayListener() { + if (_gatewayBaseUrl == null) return; + _connectSSEWith( + clientHolder: (c) => _iphoneRelayClient = c, + onEvent: (map) { + // Only handle events from iPad destined for Watch + if (map['source'] == 'ipad' && map['type'] == 'ai_reply') { + final text = map['text'] as String?; + if (text != null && text.isNotEmpty) { + _methodChannel.invokeMethod('sendReply', {'text': text}); + } + } + }, + reconnect: _startIPhoneRelayListener, + ); + } + + /// Subscribe to gateway SSE for Watch events (iPad mode). + static void _startRelaySubscription() { + if (_gatewayBaseUrl == null) return; + _relayController?.close(); + _relayController = StreamController.broadcast(); + + _connectSSEWith( + clientHolder: (c) => _sseClient = c, + onEvent: (map) { + _relayController?.add(WatchVoiceEvent.fromMap(map)); + }, + reconnect: _startRelaySubscription, + ); + } + + /// Generic SSE connector. [onEvent] fires for each parsed relay event. + /// [reconnect] is called after a 2s delay on disconnect. + static Future _connectSSEWith({ + required void Function(http.Client) clientHolder, + required void Function(Map) onEvent, + required void Function() reconnect, + }) async { + final url = '$_gatewayBaseUrl/watch/relay'; + try { + final client = http.Client(); + clientHolder(client); + final request = http.Request('GET', Uri.parse(url)); + final response = await client.send(request); + + response.stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + (line) { + if (!line.startsWith('data: ')) return; + final json = line.substring(6); + try { + final map = jsonDecode(json) as Map; + if (map['type'] == 'connected') return; // skip handshake + onEvent(map); + } catch (_) {} + }, + onDone: () => Future.delayed(const Duration(seconds: 2), reconnect), + onError: (_) => Future.delayed(const Duration(seconds: 2), reconnect), + ); + } catch (_) { + Future.delayed(const Duration(seconds: 2), reconnect); + } + } /// Stream of voice events received from the Watch. /// - /// Each event carries the local file path to the transferred `.m4a` audio - /// and a timestamp. + /// On iPhone: direct from WCSession via EventChannel. + /// On iPad: from gateway relay SSE stream. static Stream get onVoiceReceived { + if (_useRelay && _relayController != null) { + debugPrint('[WatchBridge] onVoiceReceived: using relay stream'); + return _relayController!.stream; + } + debugPrint('[WatchBridge] onVoiceReceived: using EventChannel (direct WCSession)'); return _eventChannel.receiveBroadcastStream().map((event) { + debugPrint('[WatchBridge] EventChannel received: $event'); return WatchVoiceEvent.fromMap(event as Map); }); } /// Sends an AI reply string back to the Watch for display. + /// + /// On iPhone: direct via WCSession MethodChannel. + /// On iPad: POST to gateway relay, iPhone picks it up and forwards to Watch. static Future sendReplyToWatch(String text) async { - await _methodChannel.invokeMethod('sendReply', {'text': text}); + debugPrint('[WatchBridge] sendReplyToWatch: "${text.length > 80 ? '${text.substring(0, 80)}...' : text}" relay=$_useRelay'); + if (_useRelay) { + await _postToRelay({ + 'type': 'ai_reply', + 'text': text, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'source': 'ipad', + }); + } else { + await _methodChannel.invokeMethod('sendReply', {'text': text}); + } + } + + /// Broadcast a Watch event to the gateway relay (called by iPhone). + /// + /// This lets iPads and other devices see Watch events in real-time. + static Future broadcastToRelay(WatchVoiceEvent event) async { + if (_gatewayBaseUrl == null) return; + await _postToRelay({ + ...event.toJson(), + 'source': 'iphone', + }); + } + + /// Whether this bridge is using gateway relay (iPad) vs direct WCSession (iPhone). + static bool get isRelayMode => _useRelay; + + /// Sends a ping to the Watch and returns true if acknowledged. + /// Only works in direct (iPhone) mode — relay mode always returns false. + static Future pingWatch() async { + if (_useRelay) return false; + try { + final result = await _methodChannel.invokeMethod('pingWatch'); + return result ?? false; + } catch (_) { + return false; + } } /// Returns `true` if the paired Watch is currently reachable. static Future get isWatchReachable async { - final result = - await _methodChannel.invokeMethod('isWatchReachable'); - return result ?? false; + if (_useRelay) { + // In relay mode, we can't directly check — assume reachable if gateway + // relay is connected. + return _relayController != null && !(_relayController!.isClosed); + } + try { + final result = + await _methodChannel.invokeMethod('isWatchReachable'); + return result ?? false; + } catch (_) { + return false; + } + } + + static Future _postToRelay(Map data) async { + if (_gatewayBaseUrl == null) return; + try { + await http.post( + Uri.parse('$_gatewayBaseUrl/watch/relay'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(data), + ); + } catch (_) { + // Relay POST failed — non-critical, don't crash + } + } + + /// Clean up resources. + static void dispose() { + _sseClient?.close(); + _iphoneRelayClient?.close(); + _relayController?.close(); + _relayController = null; } } diff --git a/lib/src/core/watch_sync_service.dart b/lib/src/core/watch_sync_service.dart index da6d835..2124c10 100644 --- a/lib/src/core/watch_sync_service.dart +++ b/lib/src/core/watch_sync_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'agent_store.dart'; import 'health_poller.dart'; +import 'input_coordinator.dart'; import '../ui/health/health_state.dart'; /// Syncs system state from the iPhone to the Apple Watch. @@ -12,11 +13,14 @@ class WatchSyncService { WatchSyncService({ required HealthPoller healthPoller, required AgentRepository agentStore, + InputCoordinator? inputCoordinator, }) : _healthPoller = healthPoller, - _agentStore = agentStore; + _agentStore = agentStore, + _inputCoordinator = inputCoordinator; final HealthPoller _healthPoller; final AgentRepository _agentStore; + final InputCoordinator? _inputCoordinator; final _channel = const MethodChannel('art.dart.clawfree/watch'); bool _isListening = false; @@ -25,12 +29,14 @@ class WatchSyncService { void start() { _healthPoller.addListener(_scheduleSync); _agentStore.addListener(_scheduleSync); + _inputCoordinator?.addListener(_scheduleSync); _doSync(); // Initial sync (immediate) } void stop() { _healthPoller.removeListener(_scheduleSync); _agentStore.removeListener(_scheduleSync); + _inputCoordinator?.removeListener(_scheduleSync); } void updateListening(bool isListening) { @@ -61,6 +67,7 @@ class WatchSyncService { 'activeAgentCount': agentCount, 'healthLevel': levelStr, 'isListening': _isListening, + 'isPhoneActive': _inputCoordinator?.isPhoneActive ?? false, }; _channel.invokeMethod('syncWatch', data).catchError((e) { diff --git a/lib/src/devices/device_chips.dart b/lib/src/devices/device_chips.dart new file mode 100644 index 0000000..28c9934 --- /dev/null +++ b/lib/src/devices/device_chips.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import 'device_registry.dart'; + +/// Displays connected devices as a row of small chips/icons. +class DeviceChips extends StatelessWidget { + const DeviceChips({super.key, required this.devices}); + + final List devices; + + @override + Widget build(BuildContext context) { + if (devices.isEmpty) return const SizedBox.shrink(); + + return SizedBox( + height: 32, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: devices.length, + separatorBuilder: (_, _) => const SizedBox(width: 4), + itemBuilder: (context, index) { + final device = devices[index]; + return _DeviceChip(device: device); + }, + ), + ); + } +} + +class _DeviceChip extends StatelessWidget { + const _DeviceChip({required this.device}); + + final ConnectedDevice device; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final color = switch (device.status) { + DeviceStatus.online => cs.primary, + DeviceStatus.speaking => cs.error, + DeviceStatus.offline => cs.outline, + }; + + return Chip( + avatar: Icon( + _iconFor(device.deviceType), + size: 16, + color: color, + ), + label: Text( + device.deviceName, + style: TextStyle(fontSize: 11, color: color), + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide(color: color.withValues(alpha: 0.3)), + backgroundColor: color.withValues(alpha: 0.05), + ); + } + + static IconData _iconFor(String type) { + return switch (type) { + 'desktop' || 'macos' => Icons.desktop_mac, + 'tablet' || 'ipad' => Icons.tablet_mac, + 'watch' => Icons.watch, + 'web' => Icons.language, + _ => Icons.phone_iphone, + }; + } +} diff --git a/lib/src/devices/device_registry.dart b/lib/src/devices/device_registry.dart new file mode 100644 index 0000000..be2942f --- /dev/null +++ b/lib/src/devices/device_registry.dart @@ -0,0 +1,327 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../core/watch_bridge.dart'; +import '../services/openclaw_client.dart'; + +/// Status of a connected device. +enum DeviceStatus { online, offline, speaking } + +/// Watch-specific connection state (more granular than DeviceStatus). +enum WatchConnectionState { connected, disconnected, searching } + +/// Watch-specific activity status. +enum WatchActivityStatus { idle, listening, speaking } + +/// Aggregated watch state exposed by [DeviceRegistry]. +class WatchState { + const WatchState({ + this.connectionState = WatchConnectionState.disconnected, + this.activityStatus = WatchActivityStatus.idle, + this.lastSyncTime, + this.relayEnabled = true, + }); + + final WatchConnectionState connectionState; + final WatchActivityStatus activityStatus; + final DateTime? lastSyncTime; + final bool relayEnabled; + + WatchState copyWith({ + WatchConnectionState? connectionState, + WatchActivityStatus? activityStatus, + DateTime? lastSyncTime, + bool? relayEnabled, + }) => + WatchState( + connectionState: connectionState ?? this.connectionState, + activityStatus: activityStatus ?? this.activityStatus, + lastSyncTime: lastSyncTime ?? this.lastSyncTime, + relayEnabled: relayEnabled ?? this.relayEnabled, + ); +} + +/// A device connected to the OpenClaw Gateway. +class ConnectedDevice { + const ConnectedDevice({ + required this.deviceId, + required this.deviceName, + required this.deviceType, + this.status = DeviceStatus.online, + this.lastSeen, + this.isSelf = false, + }); + + final String deviceId; + final String deviceName; + final String deviceType; + final DeviceStatus status; + final DateTime? lastSeen; + final bool isSelf; + + ConnectedDevice copyWith({DeviceStatus? status, DateTime? lastSeen}) { + return ConnectedDevice( + deviceId: deviceId, + deviceName: deviceName, + deviceType: deviceType, + status: status ?? this.status, + lastSeen: lastSeen ?? this.lastSeen, + isSelf: isSelf, + ); + } + + factory ConnectedDevice.fromJson(Map json) { + return ConnectedDevice( + deviceId: json['device_id'] as String? ?? '', + deviceName: json['device_name'] as String? ?? 'Unknown', + deviceType: json['device_type'] as String? ?? 'phone', + status: _parseStatus(json['status'] as String?), + lastSeen: json['last_seen'] != null + ? DateTime.tryParse(json['last_seen'] as String) + : null, + ); + } + + static DeviceStatus _parseStatus(String? raw) { + return switch (raw) { + 'online' => DeviceStatus.online, + 'speaking' => DeviceStatus.speaking, + 'offline' => DeviceStatus.offline, + _ => DeviceStatus.online, + }; + } +} + +/// Tracks connected devices via OpenClaw Gateway polling. +/// +/// Falls back to local-only mode when the gateway doesn't support +/// `/v1/devices` (404). In local-only mode, tracks self + Watch +/// reachability via [WatchBridge]. +class DeviceRegistry extends ChangeNotifier { + DeviceRegistry({ + OpenClawClient? client, + this.pollInterval = const Duration(seconds: 10), + }) : _client = client; + + final OpenClawClient? _client; + final Duration pollInterval; + + Timer? _pollTimer; + final Map _devices = {}; + String? _selfDeviceId; + + /// When true, the gateway doesn't support `/v1/devices` and we + /// only track local devices (self + Watch via WCSession). + bool _localOnly = false; + + /// Watch-specific state. + WatchState _watchState = const WatchState(); + + /// Current watch state (connection, activity, last sync, relay toggle). + WatchState get watchState => _watchState; + + /// Whether the registry is in local-only mode (gateway has no devices API). + bool get isLocalOnly => _localOnly; + + /// All known devices. + List get devices => _devices.values.toList(); + + /// Other devices (excluding self). + List get otherDevices => + _devices.values.where((d) => !d.isSelf).toList(); + + /// Register this device and start polling. + /// + /// If the gateway returns 404 for device registration, switches to + /// local-only mode — tracking self + Watch reachability only. + Future registerAndStart({ + required String deviceId, + required String deviceName, + required String deviceType, + }) async { + _selfDeviceId = deviceId; + + if (_client == null) { + debugPrint('[DeviceRegistry] No gateway client — local-only mode'); + _localOnly = true; + } else { + try { + await _client.registerDevice( + deviceId: deviceId, + deviceName: deviceName, + deviceType: deviceType, + ); + } on OpenClawException catch (e) { + if (e.statusCode == 404) { + debugPrint('[DeviceRegistry] Gateway has no /v1/devices API — local-only mode'); + _localOnly = true; + } else { + debugPrint('[DeviceRegistry] Registration failed: $e'); + } + } catch (e) { + debugPrint('[DeviceRegistry] Registration failed: $e'); + } + } + + // Add self immediately + _devices[deviceId] = ConnectedDevice( + deviceId: deviceId, + deviceName: deviceName, + deviceType: deviceType, + status: DeviceStatus.online, + lastSeen: DateTime.now(), + isSelf: true, + ); + notifyListeners(); + + // Start polling (gateway devices or Watch reachability) + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(pollInterval, (_) => _poll()); + _poll(); + } + + /// Update watch activity status (called when InputCoordinator changes). + void updateWatchActivity(WatchActivityStatus activity) { + _watchState = _watchState.copyWith(activityStatus: activity); + notifyListeners(); + } + + /// Toggle gateway relay broadcasting for watch events. + void toggleWatchRelay(bool enabled) { + _watchState = _watchState.copyWith(relayEnabled: enabled); + notifyListeners(); + } + + /// Ping the watch and return true if acknowledged. + Future pingWatch() async { + _watchState = _watchState.copyWith( + connectionState: WatchConnectionState.searching, + ); + notifyListeners(); + + final reachable = await WatchBridge.pingWatch(); + _watchState = _watchState.copyWith( + connectionState: reachable + ? WatchConnectionState.connected + : WatchConnectionState.disconnected, + lastSyncTime: reachable ? DateTime.now() : _watchState.lastSyncTime, + ); + notifyListeners(); + return reachable; + } + + /// Update self status (e.g. when recording). + void updateSelfStatus(DeviceStatus status) { + if (_selfDeviceId == null) return; + final self = _devices[_selfDeviceId!]; + if (self != null) { + _devices[_selfDeviceId!] = self.copyWith( + status: status, + lastSeen: DateTime.now(), + ); + notifyListeners(); + } + } + + Future _poll() async { + if (_localOnly) { + await _pollWatchReachability(); + return; + } + + try { + final deviceList = await _client!.fetchDevices(); + final newIds = {}; + + for (final json in deviceList) { + final device = ConnectedDevice.fromJson(json); + newIds.add(device.deviceId); + + final isSelf = device.deviceId == _selfDeviceId; + if (isSelf) { + // Keep local status for self + final existing = _devices[device.deviceId]; + if (existing != null) continue; + } + + _devices[device.deviceId] = ConnectedDevice( + deviceId: device.deviceId, + deviceName: device.deviceName, + deviceType: device.deviceType, + status: device.status, + lastSeen: device.lastSeen ?? DateTime.now(), + isSelf: isSelf, + ); + } + + // Mark missing devices as offline + for (final id in _devices.keys.toList()) { + if (!newIds.contains(id) && id != _selfDeviceId) { + _devices[id] = _devices[id]!.copyWith(status: DeviceStatus.offline); + } + } + + notifyListeners(); + } on OpenClawException catch (e) { + if (e.statusCode == 404) { + debugPrint('[DeviceRegistry] /v1/devices returned 404 — switching to local-only'); + _localOnly = true; + await _pollWatchReachability(); + } else { + debugPrint('[DeviceRegistry] Poll failed: $e'); + } + } catch (e) { + debugPrint('[DeviceRegistry] Poll failed: $e'); + } + } + + /// In local-only mode, check Watch reachability via WCSession + /// and add/update the Watch device entry accordingly. + static const _watchDeviceId = 'apple-watch-local'; + + Future _pollWatchReachability() async { + try { + final reachable = await WatchBridge.isWatchReachable; + final existing = _devices[_watchDeviceId]; + + if (reachable) { + _devices[_watchDeviceId] = ConnectedDevice( + deviceId: _watchDeviceId, + deviceName: 'Apple Watch', + deviceType: 'watch', + status: DeviceStatus.online, + lastSeen: DateTime.now(), + ); + _watchState = _watchState.copyWith( + connectionState: WatchConnectionState.connected, + lastSyncTime: DateTime.now(), + ); + } else { + if (existing != null) { + _devices[_watchDeviceId] = existing.copyWith( + status: DeviceStatus.offline, + ); + } + _watchState = _watchState.copyWith( + connectionState: WatchConnectionState.disconnected, + ); + } + notifyListeners(); + } catch (_) { + // WatchBridge not available on this platform + } + } + + /// Stop polling. + void stop() { + _pollTimer?.cancel(); + _pollTimer = null; + } + + @override + void dispose() { + stop(); + super.dispose(); + } +} diff --git a/lib/src/services/device_role.dart b/lib/src/services/device_role.dart new file mode 100644 index 0000000..c9430c5 --- /dev/null +++ b/lib/src/services/device_role.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// The role this device plays in the local sync network. +enum SyncRole { + /// iPhone: runs the WebSocket server and broadcasts events. + host, + + /// iPad / macOS: connects to the host as a WebSocket client. + client, +} + +/// Determines the sync role for this device automatically. +/// +/// - iPhone (iOS + small screen) → host +/// - iPad (iOS + large screen) → client +/// - macOS → client +/// +/// Can be overridden manually via [overrideRole]. +class DeviceRoleDetector extends ChangeNotifier { + SyncRole? _override; + String? _hostIp; + + /// Manually override the detected role. + void overrideRole(SyncRole role, {String? hostIp}) { + _override = role; + _hostIp = hostIp; + notifyListeners(); + } + + /// The IP address of the host (only relevant for client role). + String? get hostIp => _hostIp; + + /// Clear the manual override and return to auto-detection. + void clearOverride() { + _override = null; + _hostIp = null; + notifyListeners(); + } + + /// Detect the role for this device. + /// + /// [context] is used to measure screen size on iOS to distinguish + /// iPhone from iPad. + SyncRole detect([BuildContext? context]) { + if (_override != null) return _override!; + + if (kIsWeb) return SyncRole.client; + + if (Platform.isMacOS) return SyncRole.client; + + if (Platform.isIOS) { + // Use screen shortest side to distinguish iPhone vs iPad. + // iPhone: < 500, iPad: >= 500 (logical pixels). + if (context != null) { + final shortest = MediaQuery.sizeOf(context).shortestSide; + return shortest < 500 ? SyncRole.host : SyncRole.client; + } + // Without context, assume iPhone (host) as the safer default. + return SyncRole.host; + } + + return SyncRole.client; + } +} diff --git a/lib/src/services/local_sync_client.dart b/lib/src/services/local_sync_client.dart new file mode 100644 index 0000000..b9b52d8 --- /dev/null +++ b/lib/src/services/local_sync_client.dart @@ -0,0 +1,133 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +/// Event received from the sync server. +class SyncEvent { + SyncEvent({required this.type, required this.data}); + + /// `user_message` or `ai_response`. + final String type; + final Map data; + + String get text => data['text'] as String? ?? ''; + String? get a2ui => data['a2ui'] as String?; + String get source => data['source'] as String? ?? 'unknown'; +} + +/// WebSocket client that connects to the iPhone sync server. +/// +/// Used by iPad and macOS devices to receive real-time chat updates. +class LocalSyncClient extends ChangeNotifier { + LocalSyncClient({required this.serverUrl}); + + final String serverUrl; // e.g. ws://192.168.1.5:8765 + + WebSocket? _ws; + bool _disposed = false; + bool _connected = false; + int _retryDelay = 1; + + final _eventController = StreamController.broadcast(); + + /// Stream of sync events from the server. + Stream get events => _eventController.stream; + + /// Whether currently connected. + bool get isConnected => _connected; + + /// Connect to the sync server with auto-reconnect. + Future connect() async { + if (_disposed) return; + try { + _ws = await WebSocket.connect(serverUrl); + _connected = true; + _retryDelay = 1; + debugPrint('[LocalSyncClient] connected to $serverUrl'); + notifyListeners(); + + _ws!.listen( + (data) { + try { + final map = jsonDecode(data as String) as Map; + final type = map['type'] as String? ?? ''; + _eventController.add(SyncEvent(type: type, data: map)); + } catch (e) { + debugPrint('[LocalSyncClient] parse error: $e'); + } + }, + onDone: () => _onDisconnect(), + onError: (_) => _onDisconnect(), + ); + } catch (e) { + debugPrint('[LocalSyncClient] connect failed: $e'); + _scheduleReconnect(); + } + } + + void _onDisconnect() { + _connected = false; + _ws = null; + notifyListeners(); + _scheduleReconnect(); + } + + void _scheduleReconnect() { + if (_disposed) return; + final delay = _retryDelay; + _retryDelay = min(_retryDelay * 2, 30); + debugPrint('[LocalSyncClient] reconnecting in ${delay}s...'); + Future.delayed(Duration(seconds: delay), () { + if (!_disposed) connect(); + }); + } + + /// 傳送使用者訊息到 server(由 server 轉發給其他 clients)。 + void sendUserMessage(String text, {String source = 'keyboard'}) { + _send({ + 'type': 'user_message', + 'text': text, + 'source': source, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }); + } + + /// 傳送 AI 回應到 server(由 server 轉發給其他 clients)。 + void sendAiResponse(String text, {String? a2ui}) { + _send({ + 'type': 'ai_response', + 'text': text, + // ignore: use_null_aware_elements + if (a2ui != null) 'a2ui': a2ui, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }); + } + + void _send(Map data) { + if (_ws == null || !_connected) return; + try { + _ws!.add(jsonEncode(data)); + } catch (e) { + debugPrint('[LocalSyncClient] send error: $e'); + } + } + + /// Disconnect from the server. + Future disconnect() async { + _disposed = true; + await _ws?.close(); + _ws = null; + _connected = false; + notifyListeners(); + } + + @override + void dispose() { + disconnect(); + _eventController.close(); + super.dispose(); + } +} diff --git a/lib/src/services/local_sync_server.dart b/lib/src/services/local_sync_server.dart new file mode 100644 index 0000000..3d5c26b --- /dev/null +++ b/lib/src/services/local_sync_server.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +/// Local WebSocket server for multi-device sync. +/// +/// Runs on the iPhone (host) and broadcasts chat events to connected +/// iPad/macOS clients on the same LAN. +class LocalSyncServer extends ChangeNotifier { + LocalSyncServer({this.port = 8765}); + + final int port; + HttpServer? _server; + final List _clients = []; + final _incomingController = + StreamController>.broadcast(); + + /// 從 client 裝置收到的訊息 stream(供 host 處理)。 + Stream> get incomingMessages => + _incomingController.stream; + + /// Number of currently connected clients. + int get clientCount => _clients.length; + + /// Whether the server is running. + bool get isRunning => _server != null; + + /// Start the WebSocket server. + Future start() async { + if (_server != null) return; + try { + _server = await HttpServer.bind(InternetAddress.anyIPv4, port); + debugPrint('[LocalSyncServer] listening on port $port'); + _server!.listen(_handleRequest); + notifyListeners(); + } catch (e) { + debugPrint('[LocalSyncServer] failed to start: $e'); + } + } + + Future _handleRequest(HttpRequest request) async { + if (!WebSocketTransformer.isUpgradeRequest(request)) { + request.response + ..statusCode = HttpStatus.badRequest + ..write('WebSocket only') + ..close(); + return; + } + try { + final ws = await WebSocketTransformer.upgrade(request); + _clients.add(ws); + debugPrint('[LocalSyncServer] client connected (${_clients.length} total)'); + notifyListeners(); + + ws.listen( + (data) => _handleClientMessage(ws, data), + onDone: () => _removeClient(ws), + onError: (_) => _removeClient(ws), + ); + } catch (e) { + debugPrint('[LocalSyncServer] upgrade error: $e'); + } + } + + /// 處理來自 client 的訊息,轉發給其他 clients 並通知 host。 + void _handleClientMessage(WebSocket sender, dynamic rawData) { + try { + final map = jsonDecode(rawData as String) as Map; + debugPrint('[LocalSyncServer] received from client: ${map['type']}'); + + // 通知 host(透過 stream) + _incomingController.add(map); + + // 轉發給其他 clients(排除發送者) + final json = jsonEncode(map); + final dead = []; + for (final ws in _clients) { + if (ws == sender) continue; + try { + ws.add(json); + } catch (_) { + dead.add(ws); + } + } + for (final ws in dead) { + _removeClient(ws); + } + } catch (e) { + debugPrint('[LocalSyncServer] client message parse error: $e'); + } + } + + void _removeClient(WebSocket ws) { + _clients.remove(ws); + debugPrint('[LocalSyncServer] client disconnected (${_clients.length} remaining)'); + notifyListeners(); + } + + /// 廣播原始 JSON 資料到所有 client(Watch UI 狀態同步用)。 + void broadcastRaw(Map data) { + _broadcast(data); + } + + /// Broadcast a user message to all connected clients. + void broadcastUserMessage(String text, {String source = 'keyboard'}) { + _broadcast({ + 'type': 'user_message', + 'text': text, + 'source': source, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }); + } + + /// Broadcast an AI response to all connected clients. + void broadcastAiResponse(String text, {String? a2ui}) { + _broadcast({ + 'type': 'ai_response', + 'text': text, + // ignore: use_null_aware_elements + if (a2ui != null) 'a2ui': a2ui, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }); + } + + void _broadcast(Map data) { + if (_clients.isEmpty) return; + final json = jsonEncode(data); + final dead = []; + for (final ws in _clients) { + try { + ws.add(json); + } catch (_) { + dead.add(ws); + } + } + for (final ws in dead) { + _removeClient(ws); + } + } + + /// Stop the server and disconnect all clients. + Future stop() async { + for (final ws in _clients) { + try { + await ws.close(); + } catch (_) {} + } + _clients.clear(); + await _server?.close(); + _server = null; + notifyListeners(); + } + + @override + void dispose() { + stop(); + _incomingController.close(); + super.dispose(); + } +} diff --git a/lib/src/services/openclaw_client.dart b/lib/src/services/openclaw_client.dart new file mode 100644 index 0000000..e45095d --- /dev/null +++ b/lib/src/services/openclaw_client.dart @@ -0,0 +1,193 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +/// Client for communicating with OpenClaw Gateway. +/// +/// Handles: sending text/audio, receiving responses, device registration. +/// Default endpoint: `http://192.168.68.16:18789` (configurable). +class OpenClawClient extends ChangeNotifier { + OpenClawClient({ + String baseUrl = 'http://192.168.68.16:18789', + http.Client? httpClient, + }) : _baseUrl = baseUrl, + _httpClient = httpClient ?? http.Client(); + + String _baseUrl; + final http.Client _httpClient; + + String get baseUrl => _baseUrl; + + void updateBaseUrl(String url) { + _baseUrl = url; + notifyListeners(); + } + + Map get _jsonHeaders => { + 'Content-Type': 'application/json', + }; + + /// Send a text message to the gateway and get a response. + Future sendText(String text, {String? sessionId}) async { + final uri = Uri.parse('$_baseUrl/v1/chat/completions'); + final payload = { + 'messages': [ + {'role': 'user', 'content': text}, + ], + }; + if (sessionId != null) payload['session_id'] = sessionId; + final body = jsonEncode(payload); + + try { + final response = await _httpClient + .post(uri, headers: _jsonHeaders, body: body) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body) as Map; + // Try OpenAI-compatible format + final choices = json['choices'] as List?; + if (choices != null && choices.isNotEmpty) { + final message = choices[0]['message'] as Map?; + return message?['content'] as String? ?? ''; + } + // Fallback: direct response field + return json['response'] as String? ?? json.toString(); + } + throw OpenClawException( + 'Send text failed: ${response.statusCode}', + statusCode: response.statusCode, + ); + } catch (e) { + if (e is OpenClawException) rethrow; + throw OpenClawException('Send text error: $e'); + } + } + + /// Send an audio file to the gateway for STT + processing. + Future sendAudio(String filePath, {String? sessionId}) async { + final uri = Uri.parse('$_baseUrl/v1/audio/transcriptions'); + + try { + final request = http.MultipartRequest('POST', uri); + request.files.add(await http.MultipartFile.fromPath('file', filePath)); + request.fields['model'] = 'whisper-1'; + if (sessionId != null) { + request.fields['session_id'] = sessionId; + } + + final streamedResponse = + await request.send().timeout(const Duration(seconds: 30)); + final response = await http.Response.fromStream(streamedResponse); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body) as Map; + return json['text'] as String? ?? ''; + } + throw OpenClawException( + 'Send audio failed: ${response.statusCode}', + statusCode: response.statusCode, + ); + } catch (e) { + if (e is OpenClawException) rethrow; + throw OpenClawException('Send audio error: $e'); + } + } + + /// Register this device with the gateway. + Future> registerDevice({ + required String deviceId, + required String deviceName, + required String deviceType, + }) async { + final uri = Uri.parse('$_baseUrl/v1/devices/register'); + final body = jsonEncode({ + 'device_id': deviceId, + 'device_name': deviceName, + 'device_type': deviceType, + 'platform': _platformString(), + }); + + try { + final response = await _httpClient + .post(uri, headers: _jsonHeaders, body: body) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200 || response.statusCode == 201) { + return jsonDecode(response.body) as Map; + } + throw OpenClawException( + 'Device registration failed: ${response.statusCode}', + statusCode: response.statusCode, + ); + } catch (e) { + if (e is OpenClawException) rethrow; + throw OpenClawException('Device registration error: $e'); + } + } + + /// Fetch list of connected devices from gateway. + Future>> fetchDevices() async { + final uri = Uri.parse('$_baseUrl/v1/devices'); + + try { + final response = await _httpClient + .get(uri, headers: _jsonHeaders) + .timeout(const Duration(seconds: 5)); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + if (decoded is List) return decoded.cast>(); + if (decoded is Map && decoded['devices'] is List) { + return (decoded['devices'] as List).cast>(); + } + return []; + } + throw OpenClawException( + 'Fetch devices failed: ${response.statusCode}', + statusCode: response.statusCode, + ); + } catch (e) { + if (e is OpenClawException) rethrow; + throw OpenClawException('Fetch devices error: $e'); + } + } + + /// Health check. + Future ping() async { + try { + final uri = Uri.parse('$_baseUrl/health'); + final response = await _httpClient + .get(uri) + .timeout(const Duration(seconds: 3)); + return response.statusCode == 200; + } catch (_) { + return false; + } + } + + static String _platformString() { + if (kIsWeb) return 'web'; + if (Platform.isIOS) return 'ios'; + if (Platform.isMacOS) return 'macos'; + if (Platform.isAndroid) return 'android'; + return 'unknown'; + } + + @override + void dispose() { + _httpClient.close(); + super.dispose(); + } +} + +class OpenClawException implements Exception { + const OpenClawException(this.message, {this.statusCode}); + final String message; + final int? statusCode; + + @override + String toString() => 'OpenClawException($message, statusCode: $statusCode)'; +} diff --git a/lib/src/ui/chat/chat_input_bar.dart b/lib/src/ui/chat/chat_input_bar.dart index a08893c..9bcf758 100644 --- a/lib/src/ui/chat/chat_input_bar.dart +++ b/lib/src/ui/chat/chat_input_bar.dart @@ -3,23 +3,32 @@ import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import '../../voice/audio_recorder_service.dart'; +import '../../voice/press_to_talk_button.dart'; import '../../voice/stt_service.dart'; import '../clawfree_icons.dart'; import '../theme.dart'; import '../voice_input_widget.dart'; +/// Callback with the recorded audio file path. +typedef OnAudioFileRecorded = void Function(String filePath); + /// Platform-adaptive input bar with text field, voice button, and send button. class ChatInputBar extends StatefulWidget { const ChatInputBar({ super.key, required this.controller, this.sttService, + this.audioRecorder, + this.onAudioRecorded, required this.isProcessing, required this.onSend, }); final TextEditingController controller; final SttService? sttService; + final AudioRecorderService? audioRecorder; + final OnAudioFileRecorded? onAudioRecorded; final bool isProcessing; final ValueChanged onSend; @@ -30,26 +39,40 @@ class ChatInputBar extends StatefulWidget { class _ChatInputBarState extends State { bool _isListening = false; String _interimTranscript = ''; + final _focusNode = FocusNode(); + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final bar = Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: ClawfreeTheme.isApple - ? Theme.of(context).colorScheme.surface.withValues(alpha: 0.85) - : Theme.of(context).colorScheme.surface, - boxShadow: [ - BoxShadow( - color: Theme.of(context).shadowColor.withValues(alpha: 0.05), - blurRadius: 4, - offset: const Offset(0, -1), - ), - ], + color: ClawfreeTheme.isApple ? Theme.of(context).colorScheme.surface.withValues(alpha: 0.85) : Theme.of(context).colorScheme.surface, + boxShadow: [BoxShadow(color: Theme.of(context).shadowColor.withValues(alpha: 0.05), blurRadius: 4, offset: const Offset(0, -1))], ), child: Row( children: [ - if (widget.sttService != null) + // Press-to-talk (audio file recording) takes priority if available + if (widget.audioRecorder != null && widget.onAudioRecorded != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: PressTalkButton( + recorder: widget.audioRecorder!, + onRecorded: widget.onAudioRecorded!, + onRecordingStateChanged: (recording) { + setState(() => _isListening = recording); + }, + enabled: !widget.isProcessing, + size: 44, + ), + ) + // Fallback to STT widget if no recorder + else if (widget.sttService != null) VoiceInputWidget( sttService: widget.sttService!, enabled: !widget.isProcessing, @@ -70,10 +93,7 @@ class _ChatInputBarState extends State { if (ClawfreeTheme.isApple) { return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: bar, - ), + child: BackdropFilter(filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: bar), ); } return bar; @@ -84,23 +104,21 @@ class _ChatInputBarState extends State { if (text.isEmpty) return; widget.controller.clear(); widget.onSend(text); + // Keep keyboard open after sending. + _focusNode.requestFocus(); } Widget _buildInputField() { - final hintText = _isListening - ? (_interimTranscript.isNotEmpty ? _interimTranscript : 'Listening...') - : 'Type or speak a command...'; + final hintText = _isListening ? (_interimTranscript.isNotEmpty ? _interimTranscript : 'Listening...') : 'Type or speak a command...'; final enabled = !widget.isProcessing && !_isListening; if (ClawfreeTheme.isApple) { return CupertinoTextField( controller: widget.controller, + focusNode: _focusNode, placeholder: hintText, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), enabled: enabled, onSubmitted: (_) => _submit(), textInputAction: TextInputAction.send, @@ -109,13 +127,11 @@ class _ChatInputBarState extends State { return TextField( controller: widget.controller, + focusNode: _focusNode, decoration: InputDecoration( hintText: hintText, - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(24)), - ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(24))), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), enabled: enabled, onSubmitted: (_) => _submit(), @@ -131,21 +147,11 @@ class _ChatInputBarState extends State { padding: EdgeInsets.zero, minimumSize: const Size(36, 36), onPressed: widget.isProcessing ? null : _submit, - child: Icon( - ClawfreeIcons.send, - size: 32, - color: widget.isProcessing - ? CupertinoColors.systemGrey - : Theme.of(context).colorScheme.primary, - ), + child: Icon(ClawfreeIcons.send, size: 32, color: widget.isProcessing ? CupertinoColors.systemGrey : Theme.of(context).colorScheme.primary), ), ); } - return IconButton.filled( - icon: Icon(ClawfreeIcons.send), - tooltip: 'Send message (\u2318Enter)', - onPressed: widget.isProcessing ? null : _submit, - ); + return IconButton.filled(icon: Icon(ClawfreeIcons.send), tooltip: 'Send message (\u2318Enter)', onPressed: widget.isProcessing ? null : _submit); } } diff --git a/lib/src/ui/chat/chat_message_bubble.dart b/lib/src/ui/chat/chat_message_bubble.dart index 26de914..c80f170 100644 --- a/lib/src/ui/chat/chat_message_bubble.dart +++ b/lib/src/ui/chat/chat_message_bubble.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -35,6 +37,11 @@ class ChatMessageBubble extends StatelessWidget { ); } + final cs = Theme.of(context).colorScheme; + final radius = BorderRadius.circular( + ClawfreeTheme.isApple ? 18 : 16, + ); + final bubble = Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( @@ -42,19 +49,31 @@ class ChatMessageBubble extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), constraints: BoxConstraints(maxWidth: maxBubbleWidth), decoration: BoxDecoration( - color: isUser - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular( - ClawfreeTheme.isApple ? 18 : 16, - ), + // AI messages get a subtle gradient; user messages stay solid + gradient: isUser + ? null + : LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + cs.surfaceContainerHighest, + cs.surfaceContainerHighest.withValues(alpha: 0.85), + ], + ), + color: isUser ? cs.primary : null, + borderRadius: radius, + border: isUser + ? null + : Border.all( + color: Colors.white.withValues(alpha: 0.05), + width: 0.5, + ), ), child: Text( text, style: TextStyle( - color: isUser - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurface, + color: isUser ? cs.onPrimary : cs.onSurface, + height: 1.4, ), ), ), @@ -170,6 +189,76 @@ class _ErrorBubbleState extends State<_ErrorBubble> { } } +/// Three bouncing dots typing indicator for AI responses. +class TypingIndicator extends StatefulWidget { + const TypingIndicator({super.key, this.color}); + + final Color? color; + + @override + State createState() => _TypingIndicatorState(); +} + +class _TypingIndicatorState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final dotColor = + widget.color ?? Theme.of(context).colorScheme.onSurfaceVariant; + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) => Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final delay = i * 0.2; + final t = ((_controller.value - delay) % 1.0).clamp(0.0, 1.0); + final bounce = math.sin(t * math.pi); + return Transform.translate( + offset: Offset(0, -4 * bounce), + child: Container( + width: 7, + height: 7, + margin: EdgeInsets.only(right: i < 2 ? 4 : 0), + decoration: BoxDecoration( + color: dotColor.withValues(alpha: 0.4 + 0.6 * bounce), + shape: BoxShape.circle, + ), + ), + ); + }), + ), + ), + ), + ); + } +} + class _MessageContextMenu extends StatelessWidget { const _MessageContextMenu({required this.message, required this.child}); diff --git a/lib/src/ui/chat_screen.dart b/lib/src/ui/chat_screen.dart index 83e9cee..2b8a45a 100644 --- a/lib/src/ui/chat_screen.dart +++ b/lib/src/ui/chat_screen.dart @@ -1,16 +1,31 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + +import 'watch_flow_overlay.dart'; import 'package:qr_flutter/qr_flutter.dart'; import '../core/chat_session.dart'; import '../core/health_poller.dart'; +import '../core/input_coordinator.dart'; import '../core/platform_config.dart'; import '../core/prompt_library.dart'; import '../core/remote_session.dart'; +import '../core/service_locator.dart'; +import '../core/watch_bridge.dart'; import '../core/watch_sync_service.dart'; +import '../devices/device_registry.dart'; +import '../services/device_role.dart'; +import '../services/local_sync_client.dart'; +import '../services/local_sync_server.dart'; +import '../services/openclaw_client.dart'; import '../voice/stt_service.dart'; +import '../voice/tts_service.dart'; +import '../voice/voice_controller.dart'; +import 'settings/devices_page.dart'; +import 'settings/watch_management_panel.dart'; import 'widgets/qr_scanner_dialog.dart'; import 'clawfree_assets.dart'; import 'clawfree_icons.dart'; @@ -43,9 +58,14 @@ class _ChatScreenState extends State { // Voice state for phone layout bool _isListening = false; + bool _isSpeaking = false; String _interimTranscript = ''; bool _handsFreeEnabled = false; + // TTS polling timer for speaking state + Timer? _ttsPollTimer; + StreamSubscription? _watchSub; + // Health state — optimistic nominal default so vitals show green immediately. // The HealthPoller overrides with live data when the REST endpoint is available. HealthState _healthState = HealthState.nominal(); @@ -53,6 +73,17 @@ class _ChatScreenState extends State { List _remoteSessions = []; WatchSyncService? _watchSync; + DeviceRegistry? _deviceRegistry; + final _inputCoordinator = InputCoordinator(); + + // Local sync for multi-device broadcast + final _roleDetector = DeviceRoleDetector(); + LocalSyncServer? _syncServer; + LocalSyncClient? _syncClient; + + /// Watch 互動流程的即時狀態(從 Watch 同步過來) + Map? _watchUIState; + StreamSubscription? _syncSub; @override void initState() { @@ -68,12 +99,90 @@ class _ChatScreenState extends State { _watchSync = WatchSyncService( healthPoller: _healthPoller!, agentStore: _session.agentStore, + inputCoordinator: _inputCoordinator, ); _watchSync!.start(); // Start polling immediately so vitals update as soon as possible. _healthPoller!.start(); } + + // Initialize device registry for tracking connected devices. + // Works with or without gateway — falls back to local-only mode. + OpenClawClient? openClawClient; + if (_session.gatewayClient != null) { + openClawClient = OpenClawClient( + baseUrl: _session.gatewayClient!.baseUrl, + ); + } + _deviceRegistry = DeviceRegistry(client: openClawClient); + _deviceRegistry!.registerAndStart( + deviceId: 'self-iphone', + deviceName: 'iPhone', + deviceType: 'phone', + ); + + // Initialize local sync based on device role + _initLocalSync(); + + // Listen to input coordinator for UI rebuilds + _inputCoordinator.addListener(_onCoordinatorChanged); + + // Poll TTS speaking state to drive VoiceOrb animation + _ttsPollTimer = Timer.periodic(const Duration(milliseconds: 200), (_) { + final tts = sl.tryGet(); + if (tts != null && mounted) { + final speaking = tts.isSpeaking; + if (speaking != _isSpeaking) { + setState(() => _isSpeaking = speaking); + // Auto-restart listening after TTS finishes in hands-free mode + if (!speaking && _handsFreeEnabled && !_isListening) { + _startListening(); + } + } + } + }); + + // Listen for Watch voice events + _initWatchBridge(); + } + + void _initWatchBridge() { + final gatewayUrl = _session.gatewayClient?.baseUrl; + debugPrint('[ChatScreen] _initWatchBridge: gatewayUrl=$gatewayUrl'); + if (gatewayUrl != null) { + WatchBridge.configure(gatewayUrl: gatewayUrl); + } + + try { + _watchSub = WatchBridge.onVoiceReceived.listen( + (event) { + debugPrint('[ChatScreen] Watch event received: type=${event.type} text="${event.text}" isTextCommand=${event.isTextCommand}'); + if (event.isUIState && event.uiState != null) { + // Watch UI 狀態同步 → 廣播到 iPad/macOS + 更新本機顯示 + debugPrint('[ChatScreen] Watch UI state: ${event.uiState}'); + _syncServer?.broadcastRaw(event.uiState!); + if (mounted) setState(() { _watchUIState = event.uiState; }); + } else if (event.isTextCommand && event.text!.isNotEmpty) { + debugPrint('[ChatScreen] Forwarding Watch command to chat: "${event.text}"'); + // (a) Display in input field + if (mounted) { + setState(() { + _textController.text = event.text!; + }); + } + // (b) Auto-send to ChatSession + _send(event.text!, source: InputSource.watch); + WatchBridge.broadcastToRelay(event); + } + }, + onError: (e) => debugPrint('[ChatScreen] Watch stream error: $e'), + onDone: () => debugPrint('[ChatScreen] Watch stream closed'), + ); + debugPrint('[ChatScreen] Watch bridge listener active'); + } catch (e) { + debugPrint('[ChatScreen] Watch bridge not available: $e'); + } } void _onHealthChanged() { @@ -85,9 +194,33 @@ class _ChatScreenState extends State { } } + void _onCoordinatorChanged() { + if (mounted) setState(() {}); + } + void _onSessionChanged() { _scrollToBottom(); + // Send AI text replies to Watch and sync devices + if (_session.messages.isNotEmpty) { + final last = _session.messages.last; + if (!last.isUser && !last.isSurface && !_session.isProcessing && last.text != null) { + WatchBridge.sendReplyToWatch(last.text!).catchError((_) => null); + _syncServer?.broadcastAiResponse(last.text!); + } + } + + // Track pipeline completion for queued input auto-replay + if (!_session.isProcessing && + _inputCoordinator.state != PipelineState.idle) { + final queued = _inputCoordinator.markComplete(); + if (queued != null) { + Future.delayed(const Duration(milliseconds: 300), () { + if (mounted) _send(queued.$2, source: queued.$1); + }); + } + } + // Ensure widget rebuilds for session state changes. if (mounted) setState(() {}); } @@ -166,6 +299,15 @@ class _ChatScreenState extends State { ], ), actions: [ + if (_syncDeviceCount > 0) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Chip( + avatar: const Icon(Icons.devices, size: 16), + label: Text('$_syncDeviceCount'), + visualDensity: VisualDensity.compact, + ), + ), if (_session.isProcessing) Padding( padding: const EdgeInsets.all(12), @@ -192,15 +334,37 @@ class _ChatScreenState extends State { final formFactor = PlatformConfig.formFactor(context); _session.deviceFormFactor = formFactor; + Widget layout; switch (formFactor) { case DeviceFormFactor.phone: - return _buildPhoneLayout(); + layout = _buildPhoneLayout(); case DeviceFormFactor.tablet: case DeviceFormFactor.desktop: - return _buildTabletLayout(context); + layout = _buildTabletLayout(context); case DeviceFormFactor.watch: - return _buildWatchLayout(); + layout = _buildWatchLayout(); } + + // Watch UI 狀態同步覆蓋層 + if (_watchUIState != null) { + return Stack( + children: [ + layout, + Positioned( + left: 0, + right: 0, + bottom: 80, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: WatchFlowOverlay(state: _watchUIState!), + ), + ), + ), + ], + ); + } + return layout; } // --------------------------------------------------------------------------- @@ -230,6 +394,7 @@ class _ChatScreenState extends State { isProcessing: _session.isProcessing, healthState: _healthState, isListening: _isListening, + isSpeaking: _isSpeaking, interimTranscript: _interimTranscript, isHomeDashboard: isHome, activeAgentName: activeAgent, @@ -241,6 +406,11 @@ class _ChatScreenState extends State { onToggleHandsFree: _toggleHandsFree, onQuickAction: _send, onPairDevice: () => _showPairingModal(_session.pairingUrl), + onViewDevices: _navigateToDevices, + onManageWatch: _showWatchManagementPanel, + activeInputSource: _inputCoordinator.activeSource, + queuedInputSource: _inputCoordinator.queuedSource, + watchConnectionState: _deviceRegistry?.watchState.connectionState, ); } @@ -482,6 +652,83 @@ class _ChatScreenState extends State { } } + // --------------------------------------------------------------------------- + // Devices + // --------------------------------------------------------------------------- + + void _showWatchManagementPanel() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => WatchManagementPanel(registry: _deviceRegistry!), + ); + } + + void _navigateToDevices() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DevicesPage(registry: _deviceRegistry!), + ), + ); + } + + // --------------------------------------------------------------------------- + // Local Sync + // --------------------------------------------------------------------------- + + void _initLocalSync() { + // Defer role detection until after first frame (needs MediaQuery). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final role = _roleDetector.detect(context); + debugPrint('[ChatScreen] Device sync role: $role'); + if (role == SyncRole.host) { + _syncServer = LocalSyncServer(); + _syncServer!.addListener(() { + if (mounted) setState(() {}); + }); + _syncServer!.start(); + // 監聽從 client 裝置傳來的訊息 + _syncServer!.incomingMessages.listen((msg) { + if (!mounted) return; + final type = msg['type'] as String? ?? ''; + final text = msg['text'] as String? ?? ''; + if (type == 'user_message' && text.isNotEmpty) { + _session.sendMessage(text); + } + }); + } else { + // Client 模式 — 嘗試連線到 host + final ip = _roleDetector.hostIp ?? '127.0.0.1'; + _startSyncClient(ip); + } + }); + } + + void _startSyncClient(String hostIp) { + _syncClient = LocalSyncClient(serverUrl: 'ws://$hostIp:8765'); + _syncClient!.addListener(() { + if (mounted) setState(() {}); + }); + _syncSub = _syncClient!.events.listen((event) { + if (event.type == 'user_message') { + _session.sendMessage(event.text); + } else if (event.type == 'ui_state') { + // Watch UI 狀態從 server 同步過來 + if (mounted) setState(() { _watchUIState = event.data; }); + } + // ai_response events update via the normal ChatSession flow + }); + _syncClient!.connect(); + } + + /// Number of connected sync devices (for UI badge). + int get _syncDeviceCount { + if (_syncServer != null) return _syncServer!.clientCount; + if (_syncClient != null && _syncClient!.isConnected) return 1; + return 0; + } + // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- @@ -493,45 +740,115 @@ class _ChatScreenState extends State { _send(text); } - void _send(String text) { + void _send(String text, {InputSource source = InputSource.phone}) { HapticFeedback.lightImpact(); - _session.sendMessage(text); + final immediate = _inputCoordinator.submit(source, text); + if (immediate != null) { + _session.sendMessage(immediate); + // Broadcast to connected sync devices + final srcName = source == InputSource.watch + ? 'watch' + : source == InputSource.phone + ? 'voice' + : 'keyboard'; + _syncServer?.broadcastUserMessage(immediate, source: srcName); + // Client 模式:傳送訊息到 server 轉發給其他裝置 + _syncClient?.sendUserMessage(immediate, source: srcName); + } } void _toggleVoice() { + // If TTS is speaking, stop it first + final tts = sl.tryGet(); + if (_isSpeaking && tts != null) { + tts.stop(); + setState(() => _isSpeaking = false); + return; + } + if (_isListening) { - widget.sttService?.stopListening(); - _watchSync?.updateListening(false); - setState(() { - _isListening = false; - if (_interimTranscript.isNotEmpty) { - _send(_interimTranscript); - _interimTranscript = ''; - } - }); + _stopListening(sendTranscript: true); } else { + _startListening(); + } + } + + Future _startListening() async { + // Don't start phone recording if watch is active + if (!_inputCoordinator.requestAccess(InputSource.phone)) return; + + // Check STT availability before updating UI state + final stt = widget.sttService; + if (stt != null && !await stt.isAvailable) { + debugPrint('[ChatScreen] STT not available (permission denied or unsupported)'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Speech recognition unavailable. Check microphone permissions in Settings.'), + duration: Duration(seconds: 3), + ), + ); + } + return; + } + + final vc = sl.tryGet(); + if (vc != null) { setState(() { _isListening = true; _interimTranscript = ''; }); _watchSync?.updateListening(true); - widget.sttService?.startListening(onResult: (transcript, isFinal) { - setState(() => _interimTranscript = transcript); - if (isFinal && transcript.isNotEmpty) { - widget.sttService?.stopListening(); - _watchSync?.updateListening(false); - setState(() { - _isListening = false; - _interimTranscript = ''; - }); - _send(transcript); - } + vc.startListening(onResult: _onSttResult); + } else if (stt != null) { + setState(() { + _isListening = true; + _interimTranscript = ''; }); + _watchSync?.updateListening(true); + stt.startListening(onResult: _onSttResult); + } else { + debugPrint('[ChatScreen] No STT service available'); + } + } + + void _stopListening({bool sendTranscript = false}) { + final vc = sl.tryGet(); + if (vc != null) { + vc.stopListening(); + } else { + widget.sttService?.stopListening(); + } + _watchSync?.updateListening(false); + setState(() { + _isListening = false; + if (sendTranscript && _interimTranscript.isNotEmpty) { + _send(_interimTranscript); + } + _interimTranscript = ''; + }); + } + + void _onSttResult(String transcript, bool isFinal) { + if (!mounted) return; + setState(() => _interimTranscript = transcript); + if (isFinal && transcript.isNotEmpty) { + _stopListening(); + _send(transcript); } } void _toggleHandsFree() { - setState(() => _handsFreeEnabled = !_handsFreeEnabled); + final newValue = !_handsFreeEnabled; + setState(() => _handsFreeEnabled = newValue); + + final vc = sl.tryGet(); + if (vc != null) { + vc.setHandsFreeMode( + enabled: newValue, + onCommand: newValue ? _onSttResult : null, + ); + } } void _scrollToBottom() { @@ -551,9 +868,17 @@ class _ChatScreenState extends State { _session.onNavigateBack = null; _session.onPairingRequested = null; _session.removeListener(_onSessionChanged); + _inputCoordinator.removeListener(_onCoordinatorChanged); + _inputCoordinator.dispose(); _healthPoller?.removeListener(_onHealthChanged); _healthPoller?.dispose(); _watchSync?.stop(); + _deviceRegistry?.dispose(); + _ttsPollTimer?.cancel(); + _watchSub?.cancel(); + _syncSub?.cancel(); + _syncServer?.dispose(); + _syncClient?.dispose(); _textController.dispose(); _scrollController.dispose(); super.dispose(); diff --git a/lib/src/ui/layouts/phone_layout.dart b/lib/src/ui/layouts/phone_layout.dart index a184514..8f30e4b 100644 --- a/lib/src/ui/layouts/phone_layout.dart +++ b/lib/src/ui/layouts/phone_layout.dart @@ -1,15 +1,20 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:genui/genui.dart'; +import '../../core/input_coordinator.dart'; import '../../core/message_item.dart'; import '../../core/prompt_library.dart'; import '../../core/remote_session.dart'; +import '../../devices/device_registry.dart'; import '../../voice/stt_service.dart'; import '../chat/chat_input_bar.dart'; import '../chat/chat_message_list.dart'; import '../chat/chat_surface_view.dart'; import '../health/health_indicators.dart'; +import '../theme.dart'; import '../widgets/remote_session_indicator.dart'; import 'voice_orb.dart'; @@ -29,6 +34,7 @@ class PhoneLayout extends StatelessWidget { required this.isProcessing, required this.healthState, required this.isListening, + this.isSpeaking = false, required this.interimTranscript, required this.isHomeDashboard, required this.activeAgentName, @@ -39,9 +45,14 @@ class PhoneLayout extends StatelessWidget { required this.onToggleHandsFree, required this.onQuickAction, this.onPairDevice, + this.onViewDevices, + this.onManageWatch, this.sessionMode = SessionMode.home, this.activeSurfaceId, this.remoteSessions = const [], + this.activeInputSource, + this.queuedInputSource, + this.watchConnectionState, }); final SessionMode sessionMode; @@ -53,6 +64,7 @@ class PhoneLayout extends StatelessWidget { final bool isProcessing; final HealthState healthState; final bool isListening; + final bool isSpeaking; final String interimTranscript; final bool isHomeDashboard; final String? activeAgentName; @@ -63,13 +75,18 @@ class PhoneLayout extends StatelessWidget { final VoidCallback onToggleHandsFree; final ValueChanged onQuickAction; final VoidCallback? onPairDevice; + final VoidCallback? onViewDevices; + final VoidCallback? onManageWatch; final String? activeSurfaceId; final List remoteSessions; + final InputSource? activeInputSource; + final InputSource? queuedInputSource; + final WatchConnectionState? watchConnectionState; Color _accentForMode(BuildContext context) { return switch (sessionMode) { SessionMode.onboarding => const Color(0xFF9C27B0), - SessionMode.home => const Color(0xFF2196F3), + SessionMode.home => const Color(0xFF00BFA5), SessionMode.agentBuilder => Theme.of(context).colorScheme.primary, }; } @@ -92,6 +109,16 @@ class PhoneLayout extends StatelessWidget { activeAgentName: activeAgentName, remoteSessions: remoteSessions, ), + // -- Watch active banner -- + if (activeInputSource == InputSource.watch) + _ActiveSourceBanner( + icon: Icons.watch, + label: 'Watch is talking', + color: ClawfreeTheme.teal, + ), + if (queuedInputSource == InputSource.phone && + activeInputSource == InputSource.watch) + _QueuedBanner(), // -- Center: Voice Orb + latest surface -- Expanded( child: Center( @@ -102,6 +129,7 @@ class PhoneLayout extends StatelessWidget { children: [ VoiceOrb( isListening: isListening, + isSpeaking: isSpeaking, interimTranscript: interimTranscript, onTap: onToggleVoice, accentColor: _accentForMode(context), @@ -113,6 +141,42 @@ class PhoneLayout extends StatelessWidget { maxWidth: width - 32, activeSurfaceId: activeSurfaceId, ), + if (messages.where((m) => m.isSurface).isEmpty) ...[ + const SizedBox(height: 16), + Text( + 'What would you like to build?', + style: TextStyle( + fontSize: 15, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + _SuggestionChip( + label: '🤖 Create an agent', + onTap: () => onQuickAction('Create a new agent'), + ), + _SuggestionChip( + label: '⌚ Pair Apple Watch', + onTap: () { + if (onPairDevice != null) { + onPairDevice!(); + } else { + onQuickAction('Pair a device'); + } + }, + ), + _SuggestionChip( + label: '📊 Show analytics', + onTap: () => onQuickAction('Show analytics'), + ), + ], + ), + ], ], ), ), @@ -124,6 +188,9 @@ class PhoneLayout extends StatelessWidget { handsFreeEnabled: handsFreeEnabled, onToggleHandsFree: onToggleHandsFree, onPairDevice: onPairDevice, + onViewDevices: onViewDevices, + onManageWatch: onManageWatch, + watchConnectionState: watchConnectionState, ), ], ); @@ -180,13 +247,17 @@ class _ConnectivityBar extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return ClipRect( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: Theme.of(context).colorScheme.surfaceContainerHighest + .withValues(alpha: 0.7), border: Border( bottom: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, + color: Colors.white.withValues(alpha: 0.06), width: 0.5, ), ), @@ -231,6 +302,8 @@ class _ConnectivityBar extends StatelessWidget { ), ], ), + ), + ), ); } } @@ -288,12 +361,18 @@ class _QuickActionGrid extends StatelessWidget { required this.handsFreeEnabled, required this.onToggleHandsFree, this.onPairDevice, + this.onViewDevices, + this.onManageWatch, + this.watchConnectionState, }); final ValueChanged onQuickAction; final bool handsFreeEnabled; final VoidCallback onToggleHandsFree; final VoidCallback? onPairDevice; + final VoidCallback? onViewDevices; + final VoidCallback? onManageWatch; + final WatchConnectionState? watchConnectionState; @override Widget build(BuildContext context) { @@ -326,9 +405,17 @@ class _QuickActionGrid extends StatelessWidget { ), _QuickActionItem( icon: Icons.watch, - label: 'Pair Watch', + label: watchConnectionState == WatchConnectionState.connected + ? 'Watch' + : 'Pair Watch', + badgeColor: watchConnectionState == WatchConnectionState.connected + ? const Color(0xFF34C759) + : null, onTap: () { - if (onPairDevice != null) { + if (watchConnectionState == WatchConnectionState.connected && + onManageWatch != null) { + onManageWatch!(); + } else if (onPairDevice != null) { onPairDevice!(); } else { onQuickAction('Pair a device'); @@ -336,9 +423,15 @@ class _QuickActionGrid extends StatelessWidget { }, ), _QuickActionItem( - icon: Icons.extension, - label: 'Skills', - onTap: () => onQuickAction('Show skill library'), + icon: Icons.devices, + label: 'Devices', + onTap: () { + if (onViewDevices != null) { + onViewDevices!(); + } else { + onQuickAction('Show connected devices'); + } + }, ), ], ), @@ -379,42 +472,268 @@ class _QuickActionGrid extends StatelessWidget { } } -class _QuickActionItem extends StatelessWidget { +class _SuggestionChip extends StatelessWidget { + const _SuggestionChip({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ActionChip( + label: Text(label, style: const TextStyle(fontSize: 13)), + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + side: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + onPressed: () { + HapticFeedback.lightImpact(); + onTap(); + }, + ); + } +} + +class _QuickActionItem extends StatefulWidget { const _QuickActionItem({ required this.icon, required this.label, required this.onTap, + this.badgeColor, }); final IconData icon; final String label; final VoidCallback onTap; + final Color? badgeColor; + + @override + State<_QuickActionItem> createState() => _QuickActionItemState(); +} + +class _QuickActionItemState extends State<_QuickActionItem> + with SingleTickerProviderStateMixin { + late final AnimationController _scaleController; + bool _pressed = false; + + @override + void initState() { + super.initState(); + _scaleController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 120), + lowerBound: 0.9, + upperBound: 1.0, + value: 1.0, + ); + } + + @override + void dispose() { + _scaleController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return InkWell( - onTap: () { + final primary = Theme.of(context).colorScheme.primary; + return GestureDetector( + onTapDown: (_) { + _scaleController.reverse(); + setState(() => _pressed = true); + }, + onTapUp: (_) { + _scaleController.forward(); + setState(() => _pressed = false); HapticFeedback.lightImpact(); - onTap(); + widget.onTap(); }, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 28, color: Theme.of(context).colorScheme.primary), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant, + onTapCancel: () { + _scaleController.forward(); + setState(() => _pressed = false); + }, + child: ScaleTransition( + scale: _scaleController, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: _pressed + ? [ + BoxShadow( + color: primary.withValues(alpha: 0.2), + blurRadius: 12, + spreadRadius: 1, + ), + ] + : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Icon(widget.icon, size: 28, color: primary), + if (widget.badgeColor != null) + Positioned( + right: -2, + top: -2, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: widget.badgeColor, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 1.5, + ), + ), + ), + ), + ], ), - ), - ], + const SizedBox(height: 4), + Text( + widget.label, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), ), ), ); } } + +// --------------------------------------------------------------------------- +// Active source banner ("Watch is talking") +// --------------------------------------------------------------------------- + +class _ActiveSourceBanner extends StatelessWidget { + const _ActiveSourceBanner({ + required this.icon, + required this.label, + required this.color, + }); + + final IconData icon; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: ClawfreeTheme.durationMedium, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + border: Border( + bottom: BorderSide( + color: color.withValues(alpha: 0.2), + width: 0.5, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + color: color, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 6), + _PulsingDot(color: color), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Queued input banner +// --------------------------------------------------------------------------- + +class _QueuedBanner extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + color: Colors.white.withValues(alpha: 0.04), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.queue, size: 12, color: ClawfreeTheme.textTertiary), + const SizedBox(width: 6), + Text( + 'Your input is queued', + style: TextStyle( + fontSize: 11, + color: ClawfreeTheme.textTertiary, + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Pulsing dot (reusable within this file) +// --------------------------------------------------------------------------- + +class _PulsingDot extends StatefulWidget { + const _PulsingDot({required this.color}); + final Color color; + + @override + State<_PulsingDot> createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State<_PulsingDot> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + )..repeat(reverse: true); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (context, _) { + return Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: widget.color.withValues(alpha: 0.5 + 0.5 * _ctrl.value), + shape: BoxShape.circle, + ), + ); + }, + ); + } +} diff --git a/lib/src/ui/layouts/tablet_layout.dart b/lib/src/ui/layouts/tablet_layout.dart index 9bb6a67..e44e3d1 100644 --- a/lib/src/ui/layouts/tablet_layout.dart +++ b/lib/src/ui/layouts/tablet_layout.dart @@ -73,8 +73,9 @@ class TabletLayout extends StatelessWidget { child: Row( children: [ // Left sidebar - SizedBox( + Container( width: 220, + color: Theme.of(context).colorScheme.surfaceContainerLow, child: _Sidebar( activeNodeName: activeNodeName, agentNames: agentNames, @@ -330,7 +331,7 @@ class _Sidebar extends StatelessWidget { } } -class _SidebarItem extends StatelessWidget { +class _SidebarItem extends StatefulWidget { const _SidebarItem({ required this.icon, required this.label, @@ -343,44 +344,60 @@ class _SidebarItem extends StatelessWidget { final VoidCallback onTap; final bool isActive; + @override + State<_SidebarItem> createState() => _SidebarItemState(); +} + +class _SidebarItemState extends State<_SidebarItem> { + bool _hovered = false; + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - color: isActive ? cs.primaryContainer.withValues(alpha: 0.3) : null, - child: Row( - children: [ - Icon( - icon, - size: 18, - color: isActive ? cs.primary : cs.onSurfaceVariant, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - label, - style: TextStyle( - fontSize: 13, - fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, - color: isActive ? cs.primary : cs.onSurface, - ), - overflow: TextOverflow.ellipsis, + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: InkWell( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + color: widget.isActive + ? cs.primaryContainer.withValues(alpha: 0.3) + : _hovered + ? cs.onSurface.withValues(alpha: 0.05) + : null, + child: Row( + children: [ + Icon( + widget.icon, + size: 18, + color: widget.isActive ? cs.primary : cs.onSurfaceVariant, ), - ), - if (isActive) - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: healthColor(HealthLevel.nominal), - shape: BoxShape.circle, + const SizedBox(width: 10), + Expanded( + child: Text( + widget.label, + style: TextStyle( + fontSize: 13, + fontWeight: widget.isActive ? FontWeight.w600 : FontWeight.w400, + color: widget.isActive ? cs.primary : cs.onSurface, + ), + overflow: TextOverflow.ellipsis, ), ), - ], + if (widget.isActive) + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: healthColor(HealthLevel.nominal), + shape: BoxShape.circle, + ), + ), + ], + ), ), ), ); diff --git a/lib/src/ui/layouts/voice_orb.dart b/lib/src/ui/layouts/voice_orb.dart index 86900f0..029a93f 100644 --- a/lib/src/ui/layouts/voice_orb.dart +++ b/lib/src/ui/layouts/voice_orb.dart @@ -7,6 +7,7 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import '../spring_curve.dart'; +import '../theme.dart'; /// Large pulsing voice visualizer for the phone "Mobile Remote" layout. /// @@ -21,6 +22,7 @@ class VoiceOrb extends StatefulWidget { const VoiceOrb({ super.key, required this.isListening, + this.isSpeaking = false, this.interimTranscript = '', this.onTap, this.size = 120, @@ -28,6 +30,9 @@ class VoiceOrb extends StatefulWidget { }); final bool isListening; + + /// Whether TTS is currently speaking (drives speaking animation). + final bool isSpeaking; final String interimTranscript; final VoidCallback? onTap; final double size; @@ -43,6 +48,7 @@ class _VoiceOrbState extends State with TickerProviderStateMixin { late final AnimationController _controller; late final AnimationController _tapController; + late final AnimationController _breatheController; // Haptic heartbeat during listening Timer? _hapticTimer; @@ -66,7 +72,14 @@ class _VoiceOrbState extends State upperBound: 1.0, value: 1.0, ); - if (widget.isListening) _controller.repeat(reverse: true); + // Subtle breathing animation for idle state + _breatheController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 3000), + )..repeat(reverse: true); + if (widget.isListening || widget.isSpeaking) { + _controller.repeat(reverse: true); + } _loadShader(); } @@ -87,15 +100,22 @@ class _VoiceOrbState extends State @override void didUpdateWidget(VoiceOrb old) { super.didUpdateWidget(old); - if (widget.isListening && !old.isListening) { + final wasActive = old.isListening || old.isSpeaking; + final isActive = widget.isListening || widget.isSpeaking; + + if (isActive && !wasActive) { _controller.repeat(reverse: true); - // Start haptic heartbeat — 750ms matches a calm heartbeat rhythm + } else if (!isActive && wasActive) { + _controller.stop(); + _controller.reset(); + } + + // Haptic heartbeat only while listening (not speaking) + if (widget.isListening && !old.isListening) { _hapticTimer = Timer.periodic(const Duration(milliseconds: 750), (_) { HapticFeedback.lightImpact(); }); } else if (!widget.isListening && old.isListening) { - _controller.stop(); - _controller.reset(); _hapticTimer?.cancel(); _hapticTimer = null; } @@ -106,6 +126,7 @@ class _VoiceOrbState extends State _hapticTimer?.cancel(); _controller.dispose(); _tapController.dispose(); + _breatheController.dispose(); _shaderTicker?.dispose(); _shader?.dispose(); super.dispose(); @@ -128,7 +149,11 @@ class _VoiceOrbState extends State @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final baseColor = widget.isListening ? cs.error : cs.primary; + final baseColor = widget.isListening + ? cs.error + : widget.isSpeaking + ? cs.tertiary + : cs.primary; return Column( mainAxisSize: MainAxisSize.min, @@ -140,112 +165,179 @@ class _VoiceOrbState extends State child: ScaleTransition( scale: _tapController, child: AnimatedBuilder( - animation: _controller, + animation: Listenable.merge([_controller, _breatheController]), builder: (context, child) { - final pulse = widget.isListening ? _controller.value : 0.0; + final active = widget.isListening || widget.isSpeaking; + final pulse = active ? _controller.value : 0.0; + final breathe = _breatheController.value; // Apply spring easing to pulse rings const spring = SpringCurve(damping: 0.5, stiffness: 6.0); final springPulse = spring.transform(pulse.clamp(0.0, 1.0)); - return SizedBox( - width: widget.size + 40, - height: widget.size + 40, - child: Stack( - alignment: Alignment.center, - children: [ - // Outer pulse ring - if (widget.isListening) - Container( - width: widget.size + 40 * springPulse, - height: widget.size + 40 * springPulse, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: baseColor - .withValues(alpha: 0.3 - 0.3 * pulse), - width: 2, + // Idle breathing scale + final idleScale = active ? 1.0 : 1.0 + 0.02 * breathe; + + return Transform.scale( + scale: idleScale, + child: SizedBox( + width: widget.size + 40, + height: widget.size + 40, + child: Stack( + alignment: Alignment.center, + children: [ + // Outer pulse ring + if (active) + Container( + width: widget.size + 40 * springPulse, + height: widget.size + 40 * springPulse, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: baseColor + .withValues(alpha: 0.3 - 0.3 * pulse), + width: 2, + ), ), ), - ), - // Middle pulse ring - if (widget.isListening) - Container( - width: widget.size + 20 * springPulse, - height: widget.size + 20 * springPulse, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: baseColor.withValues(alpha: 0.2), - width: 1.5, + // Middle pulse ring + if (active) + Container( + width: widget.size + 20 * springPulse, + height: widget.size + 20 * springPulse, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: baseColor.withValues(alpha: 0.2), + width: 1.5, + ), ), ), - ), - // Shader blob or waveform fallback (only when listening) - if (widget.isListening) - _shader != null - ? CustomPaint( - size: Size( - widget.size - 8, widget.size - 8), - painter: _BlobShaderPainter( - shader: _shader!, - elapsed: _elapsed, - amplitude: pulse, - color: baseColor, - ), - ) - : CustomPaint( - size: Size( - widget.size - 8, widget.size - 8), - painter: _WaveformPainter( - color: - baseColor.withValues(alpha: 0.3), - phase: - _controller.value * 2 * math.pi, - ), + // Inner subtle ring (3rd layer) + if (active) + Container( + width: widget.size + 8 * springPulse, + height: widget.size + 8 * springPulse, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: baseColor.withValues(alpha: 0.15), + width: 1, ), - // Core orb - AnimatedContainer( - duration: const Duration(milliseconds: 300), - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: baseColor.withValues(alpha: 0.15), - border: Border.all( - color: baseColor.withValues(alpha: 0.6), - width: 2.5, + ), ), - boxShadow: widget.isListening - ? [ - // Triple-layered glow bloom - BoxShadow( - color: baseColor.withValues( - alpha: 0.15 + 0.05 * pulse), - blurRadius: 40 + 10 * pulse, - spreadRadius: 2, - ), - BoxShadow( - color: baseColor.withValues( - alpha: 0.25 + 0.05 * pulse), - blurRadius: 20 + 5 * pulse, - spreadRadius: 1, + // Wave bars around the orb (voice visualization) + if (active) + CustomPaint( + size: Size(widget.size + 30, widget.size + 30), + painter: _WaveBarsPainter( + color: baseColor.withValues(alpha: 0.4), + phase: _controller.value * 2 * math.pi, + barCount: 24, + radius: widget.size / 2 + 4, + ), + ), + // Shader blob or waveform fallback (when active) + if (active) + _shader != null + ? CustomPaint( + size: Size( + widget.size - 8, widget.size - 8), + painter: _BlobShaderPainter( + shader: _shader!, + elapsed: _elapsed, + amplitude: pulse, + color: baseColor, ), - BoxShadow( - color: baseColor.withValues( - alpha: 0.35 + 0.05 * pulse), - blurRadius: 8 + 3 * pulse, - spreadRadius: 0, + ) + : CustomPaint( + size: Size( + widget.size - 8, widget.size - 8), + painter: _WaveformPainter( + color: + baseColor.withValues(alpha: 0.3), + phase: + _controller.value * 2 * math.pi, ), - ] - : null, + ), + // Glassmorphism frosted glass background + ClipOval( + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: AnimatedContainer( + duration: ClawfreeTheme.durationMedium, + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: baseColor.withValues(alpha: 0.1), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), + width: 1.5, + ), + ), + ), + ), ), - child: Icon( - widget.isListening ? Icons.mic : Icons.mic_none, - size: widget.size * 0.4, - color: baseColor, + // Core orb (on top of glass) + AnimatedContainer( + duration: ClawfreeTheme.durationMedium, + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + baseColor.withValues(alpha: 0.2), + baseColor.withValues(alpha: 0.05), + ], + ), + border: Border.all( + color: baseColor.withValues(alpha: 0.6), + width: 2.5, + ), + boxShadow: active + ? [ + BoxShadow( + color: baseColor.withValues( + alpha: 0.15 + 0.05 * pulse), + blurRadius: 40 + 10 * pulse, + spreadRadius: 2, + ), + BoxShadow( + color: baseColor.withValues( + alpha: 0.25 + 0.05 * pulse), + blurRadius: 20 + 5 * pulse, + spreadRadius: 1, + ), + BoxShadow( + color: baseColor.withValues( + alpha: 0.35 + 0.05 * pulse), + blurRadius: 8 + 3 * pulse, + spreadRadius: 0, + ), + ] + : [ + // Subtle idle glow + BoxShadow( + color: baseColor.withValues( + alpha: 0.06 + 0.04 * breathe), + blurRadius: 20 + 5 * breathe, + spreadRadius: 1, + ), + ], + ), + child: Icon( + widget.isListening + ? Icons.mic + : widget.isSpeaking + ? Icons.volume_up + : Icons.mic_none, + size: widget.size * 0.4, + color: baseColor, + ), ), - ), - ], + ], + ), ), ); }, @@ -260,9 +352,15 @@ class _VoiceOrbState extends State ? (widget.interimTranscript.isNotEmpty ? widget.interimTranscript : 'Listening\u2026') - : 'Tap or say "Hey clawfree"', + : widget.isSpeaking + ? 'Speaking\u2026' + : 'Tap or say "Hey clawfree"', key: ValueKey( - widget.isListening ? widget.interimTranscript : 'idle'), + widget.isListening + ? widget.interimTranscript + : widget.isSpeaking + ? 'speaking' + : 'idle'), style: TextStyle( fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant, @@ -279,6 +377,52 @@ class _VoiceOrbState extends State } } +/// Draws radial wave bars around the orb for voice visualization. +class _WaveBarsPainter extends CustomPainter { + _WaveBarsPainter({ + required this.color, + required this.phase, + required this.barCount, + required this.radius, + }); + + final Color color; + final double phase; + final int barCount; + final double radius; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0 + ..strokeCap = StrokeCap.round; + + final center = Offset(size.width / 2, size.height / 2); + for (var i = 0; i < barCount; i++) { + final angle = (i / barCount) * 2 * math.pi; + final barHeight = + 4.0 + 8.0 * ((math.sin(angle * 3 + phase) + 1) / 2); + final startR = radius; + final endR = radius + barHeight; + final start = Offset( + center.dx + startR * math.cos(angle), + center.dy + startR * math.sin(angle), + ); + final end = Offset( + center.dx + endR * math.cos(angle), + center.dy + endR * math.sin(angle), + ); + canvas.drawLine(start, end, paint); + } + } + + @override + bool shouldRepaint(_WaveBarsPainter old) => + color != old.color || phase != old.phase; +} + /// Draws a sine-distorted circle as an inner waveform ring. class _WaveformPainter extends CustomPainter { _WaveformPainter({required this.color, required this.phase}); diff --git a/lib/src/ui/settings/devices_page.dart b/lib/src/ui/settings/devices_page.dart new file mode 100644 index 0000000..f3c1ef2 --- /dev/null +++ b/lib/src/ui/settings/devices_page.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +import '../../devices/device_registry.dart'; + +/// 已連線裝置設定頁面。 +/// +/// 列出 [DeviceRegistry] 中所有裝置,顯示名稱、類型、狀態與最後上線時間。 +/// 採用簡潔 dark-theme 風格,與 Clawfree 整體設計一致。 +class DevicesPage extends StatelessWidget { + const DevicesPage({super.key, required this.registry}); + + final DeviceRegistry registry; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Connected Devices')), + body: ListenableBuilder( + listenable: registry, + builder: (context, _) { + final devices = registry.devices; + if (devices.isEmpty) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.devices, size: 48, color: Colors.white24), + SizedBox(height: 12), + Text( + 'No devices connected', + style: TextStyle(color: Colors.white38), + ), + ], + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + itemCount: devices.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) => + _DeviceTile(device: devices[index]), + ); + }, + ), + ); + } +} + +class _DeviceTile extends StatelessWidget { + const _DeviceTile({required this.device}); + + final ConnectedDevice device; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final statusColor = switch (device.status) { + DeviceStatus.online => Colors.greenAccent, + DeviceStatus.speaking => cs.error, + DeviceStatus.offline => Colors.white38, + }; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: statusColor.withValues(alpha: 0.3), + width: 0.5, + ), + ), + child: Row( + children: [ + // 裝置圖示 + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(_iconFor(device.deviceType), color: statusColor, size: 22), + ), + const SizedBox(width: 14), + // 裝置資訊 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + device.deviceName, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + if (device.isSelf) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: cs.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'This Device', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: cs.primary, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 3), + Text( + _subtitle(), + style: TextStyle( + fontSize: 11, + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + // 狀態指示燈 + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: statusColor, + shape: BoxShape.circle, + ), + ), + ], + ), + ); + } + + String _subtitle() { + final type = device.deviceType; + final lastSeen = device.lastSeen; + final timeStr = lastSeen != null ? _formatTime(lastSeen) : ''; + final statusStr = device.status.name; + return [type, statusStr, if (timeStr.isNotEmpty) timeStr].join(' · '); + } + + static String _formatTime(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inSeconds < 60) return 'just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } + + static IconData _iconFor(String type) { + return switch (type) { + 'desktop' || 'macos' => Icons.desktop_mac, + 'tablet' || 'ipad' => Icons.tablet_mac, + 'watch' => Icons.watch, + 'web' => Icons.language, + _ => Icons.phone_iphone, + }; + } +} diff --git a/lib/src/ui/settings/watch_management_panel.dart b/lib/src/ui/settings/watch_management_panel.dart new file mode 100644 index 0000000..9e2bc79 --- /dev/null +++ b/lib/src/ui/settings/watch_management_panel.dart @@ -0,0 +1,418 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../core/watch_bridge.dart'; +import '../../devices/device_registry.dart'; +import '../theme.dart'; + +/// Bottom sheet panel for managing Apple Watch connection and status. +class WatchManagementPanel extends StatelessWidget { + const WatchManagementPanel({super.key, required this.registry}); + + final DeviceRegistry registry; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: registry, + builder: (context, _) { + final ws = registry.watchState; + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 24, sigmaY: 24), + child: Container( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + decoration: BoxDecoration( + color: ClawfreeTheme.darkSurface.withValues(alpha: 0.92), + borderRadius: + const BorderRadius.vertical(top: Radius.circular(20)), + border: Border.all( + color: Colors.white.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + _ConnectionHeader(ws), + const SizedBox(height: 16), + _ActivityRow(ws), + const SizedBox(height: 12), + _LastSyncRow(ws.lastSyncTime), + if (WatchBridge.isRelayMode) ...[ + const SizedBox(height: 4), + _RelayModeLabel(), + ], + const SizedBox(height: 16), + _RelayToggle( + enabled: ws.relayEnabled, + onChanged: registry.toggleWatchRelay, + ), + const SizedBox(height: 12), + _PingButton(registry: registry), + ], + ), + ), + ), + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Connection header +// --------------------------------------------------------------------------- + +class _ConnectionHeader extends StatelessWidget { + const _ConnectionHeader(this.ws); + final WatchState ws; + + @override + Widget build(BuildContext context) { + final color = _colorForConnection(ws.connectionState); + final label = switch (ws.connectionState) { + WatchConnectionState.connected => 'Connected', + WatchConnectionState.disconnected => 'Disconnected', + WatchConnectionState.searching => 'Searching...', + }; + + return Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.watch, color: color, size: 24), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Apple Watch', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + _PulsingDot(color: color, size: 7), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + color: color, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Activity row +// --------------------------------------------------------------------------- + +class _ActivityRow extends StatelessWidget { + const _ActivityRow(this.ws); + final WatchState ws; + + @override + Widget build(BuildContext context) { + final (icon, label, color) = switch (ws.activityStatus) { + WatchActivityStatus.idle => ( + Icons.circle_outlined, + 'Idle', + ClawfreeTheme.textTertiary, + ), + WatchActivityStatus.listening => ( + Icons.mic, + 'Listening', + ClawfreeTheme.teal, + ), + WatchActivityStatus.speaking => ( + Icons.volume_up, + 'Speaking', + ClawfreeTheme.lobsterOrange, + ), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: ClawfreeTheme.glassmorphism( + color: color, + opacity: 0.06, + borderRadius: 10, + ), + child: Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text( + 'Activity: $label', + style: TextStyle(fontSize: 13, color: color), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Last sync row +// --------------------------------------------------------------------------- + +class _LastSyncRow extends StatelessWidget { + const _LastSyncRow(this.lastSync); + final DateTime? lastSync; + + @override + Widget build(BuildContext context) { + final text = lastSync != null ? _formatRelative(lastSync!) : 'Never'; + return Row( + children: [ + Icon(Icons.sync, size: 14, color: ClawfreeTheme.textTertiary), + const SizedBox(width: 8), + Text( + 'Last sync: $text', + style: TextStyle(fontSize: 12, color: ClawfreeTheme.textTertiary), + ), + ], + ); + } + + static String _formatRelative(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inSeconds < 10) return 'just now'; + if (diff.inSeconds < 60) return '${diff.inSeconds}s ago'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + return '${diff.inHours}h ago'; + } +} + +// --------------------------------------------------------------------------- +// Relay mode label +// --------------------------------------------------------------------------- + +class _RelayModeLabel extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(Icons.router, size: 14, color: ClawfreeTheme.textTertiary), + const SizedBox(width: 8), + Text( + 'Connected via gateway relay', + style: TextStyle(fontSize: 12, color: ClawfreeTheme.textTertiary), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Relay toggle +// --------------------------------------------------------------------------- + +class _RelayToggle extends StatelessWidget { + const _RelayToggle({required this.enabled, required this.onChanged}); + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + decoration: ClawfreeTheme.glassmorphism( + opacity: 0.04, + borderRadius: 10, + ), + child: Row( + children: [ + Icon(Icons.broadcast_on_personal, + size: 16, color: ClawfreeTheme.textSecondary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Broadcast to relay', + style: + TextStyle(fontSize: 13, color: ClawfreeTheme.textSecondary), + ), + ), + SizedBox( + height: 28, + child: Switch.adaptive( + value: enabled, + onChanged: (v) { + HapticFeedback.selectionClick(); + onChanged(v); + }, + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Ping button +// --------------------------------------------------------------------------- + +class _PingButton extends StatefulWidget { + const _PingButton({required this.registry}); + final DeviceRegistry registry; + + @override + State<_PingButton> createState() => _PingButtonState(); +} + +class _PingButtonState extends State<_PingButton> { + bool _pinging = false; + bool? _lastResult; + + Future _ping() async { + setState(() { + _pinging = true; + _lastResult = null; + }); + HapticFeedback.lightImpact(); + final ok = await widget.registry.pingWatch(); + if (!mounted) return; + setState(() { + _pinging = false; + _lastResult = ok; + }); + // Clear result after 2 seconds + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _lastResult = null); + }); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _pinging ? null : _ping, + icon: _pinging + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : _lastResult == null + ? const Icon(Icons.radar, size: 18) + : _lastResult! + ? const Icon(Icons.check_circle, size: 18) + : const Icon(Icons.cancel, size: 18), + label: Text(_pinging + ? 'Pinging...' + : _lastResult == null + ? 'Ping Watch' + : _lastResult! + ? 'Reachable' + : 'Not Reachable'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Pulsing dot indicator +// --------------------------------------------------------------------------- + +class _PulsingDot extends StatefulWidget { + const _PulsingDot({required this.color, this.size = 8}); + final Color color; + final double size; + + @override + State<_PulsingDot> createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State<_PulsingDot> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: widget.color.withValues(alpha: 0.6 + 0.4 * _controller.value), + shape: BoxShape.circle, + ), + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +Color _colorForConnection(WatchConnectionState state) { + return switch (state) { + WatchConnectionState.connected => const Color(0xFF34C759), + WatchConnectionState.searching => const Color(0xFFFF9F0A), + WatchConnectionState.disconnected => const Color(0xFFFF3B30), + }; +} diff --git a/lib/src/ui/theme.dart b/lib/src/ui/theme.dart index 138743d..3d1b504 100644 --- a/lib/src/ui/theme.dart +++ b/lib/src/ui/theme.dart @@ -1,14 +1,72 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import '../core/platform_config.dart'; class ClawfreeTheme { - static const _primaryColor = Color(0xFF0066CC); - static const _accentColor = Color(0xFFFF6600); + // Brand colors + static const lobsterOrange = Color(0xFFFF6B35); + static const teal = Color(0xFF00BFA5); + + // Dark mode surface layers (rich depth hierarchy) + static const darkBase = Color(0xFF121212); + static const darkBg = Color(0xFF1A1A1A); + static const darkSurface = Color(0xFF242424); + static const darkSurfaceHigh = Color(0xFF2A2A2A); + static const darkSurfaceHighest = Color(0xFF333333); + + // Glow colors + static const orangeGlow = Color(0x33FF6B35); // 20% orange + static const tealGlow = Color(0x3300BFA5); + + // High-contrast text colors for WCAG AA (4.5:1 on #1A1A1A) + static const textPrimary = Color(0xFFF5F5F5); // ~15:1 + static const textSecondary = Color(0xFFBDBDBD); // ~8:1 + static const textTertiary = Color(0xFF9E9E9E); // ~5:1 + + static const _primaryColor = lobsterOrange; + static const _accentColor = teal; + + /// Standard animation durations + static const durationFast = Duration(milliseconds: 200); + static const durationMedium = Duration(milliseconds: 300); /// Whether the current platform uses Apple (Cupertino) design language. static bool get isApple => PlatformConfig.isApple; + /// Glassmorphism decoration helper + static BoxDecoration glassmorphism({ + Color? color, + double opacity = 0.08, + double borderRadius = 16, + double blurRadius = 20, + }) { + return BoxDecoration( + color: (color ?? Colors.white).withValues(alpha: opacity), + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + width: 0.5, + ), + ); + } + + /// Orange glow box shadow for focused/active elements + static List orangeGlowShadow({double intensity = 1.0}) { + return [ + BoxShadow( + color: lobsterOrange.withValues(alpha: 0.15 * intensity), + blurRadius: 20, + spreadRadius: 2, + ), + BoxShadow( + color: lobsterOrange.withValues(alpha: 0.08 * intensity), + blurRadius: 40, + spreadRadius: 4, + ), + ]; + } + static ThemeData get light { final colorScheme = ColorScheme.fromSeed( seedColor: _primaryColor, @@ -51,7 +109,7 @@ class ClawfreeTheme { ), ) : null, - textTheme: base.textTheme.merge(_textThemeOverrides(colorScheme)), + textTheme: _buildTextTheme(base.textTheme, colorScheme), ); } @@ -60,47 +118,123 @@ class ClawfreeTheme { seedColor: _primaryColor, secondary: _accentColor, brightness: Brightness.dark, + surface: darkBg, + onSurface: textPrimary, ); final base = ThemeData.from(colorScheme: colorScheme); return base.copyWith( - appBarTheme: isApple - ? AppBarTheme( - backgroundColor: colorScheme.surface.withValues(alpha: 0.95), - foregroundColor: Colors.white, - elevation: 0, - surfaceTintColor: Colors.transparent, - ) - : null, - cardTheme: isApple - ? CardThemeData( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: colorScheme.outlineVariant), - ), - ) - : null, - textTheme: base.textTheme.merge(_textThemeOverrides(colorScheme)), + scaffoldBackgroundColor: darkBg, + appBarTheme: AppBarTheme( + backgroundColor: darkBg.withValues(alpha: 0.95), + foregroundColor: Colors.white, + elevation: 0, + surfaceTintColor: Colors.transparent, + ), + cardTheme: CardThemeData( + elevation: 0, + color: darkSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.06)), + ), + ), + dividerTheme: DividerThemeData( + color: Colors.white.withValues(alpha: 0.08), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: darkSurfaceHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: _primaryColor, width: 1.5), + ), + ), + textTheme: _buildTextTheme(base.textTheme, colorScheme), ); } - /// Custom font weight overrides — merged on top of the base theme's - /// textTheme so that all styles (bodyMedium, titleMedium, etc.) keep - /// their correctly-themed colors for light/dark mode. - static TextTheme _textThemeOverrides(ColorScheme colorScheme) { - return TextTheme( - headlineLarge: TextStyle( + /// Builds text theme with Google Fonts (Space Grotesk for headlines, Inter for body). + static TextTheme _buildTextTheme(TextTheme base, ColorScheme colorScheme) { + final headlineFont = GoogleFonts.spaceGroteskTextTheme(base); + final bodyFont = GoogleFonts.interTextTheme(base); + + return bodyFont.copyWith( + // Headlines use Space Grotesk + displayLarge: headlineFont.displayLarge?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + letterSpacing: -0.5, + ), + displayMedium: headlineFont.displayMedium?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + letterSpacing: -0.5, + ), + displaySmall: headlineFont.displaySmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + headlineLarge: headlineFont.headlineLarge?.copyWith( fontWeight: FontWeight.w700, color: colorScheme.onSurface, + letterSpacing: -0.3, ), - titleLarge: TextStyle( + headlineMedium: headlineFont.headlineMedium?.copyWith( fontWeight: FontWeight.w600, color: colorScheme.onSurface, ), - bodyLarge: TextStyle( + headlineSmall: headlineFont.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + titleLarge: headlineFont.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + letterSpacing: -0.2, + ), + titleMedium: bodyFont.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + titleSmall: bodyFont.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + // Body uses Inter + bodyLarge: bodyFont.bodyLarge?.copyWith( fontWeight: FontWeight.w400, color: colorScheme.onSurface, + height: 1.5, + ), + bodyMedium: bodyFont.bodyMedium?.copyWith( + fontWeight: FontWeight.w400, + color: colorScheme.onSurface, + height: 1.5, + ), + bodySmall: bodyFont.bodySmall?.copyWith( + fontWeight: FontWeight.w400, + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + labelLarge: bodyFont.labelLarge?.copyWith( + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + labelMedium: bodyFont.labelMedium?.copyWith( + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + labelSmall: bodyFont.labelSmall?.copyWith( + fontWeight: FontWeight.w500, + letterSpacing: 0.3, ), ); } diff --git a/lib/src/ui/voice_input_widget.dart b/lib/src/ui/voice_input_widget.dart index c817783..f3539ef 100644 --- a/lib/src/ui/voice_input_widget.dart +++ b/lib/src/ui/voice_input_widget.dart @@ -54,7 +54,19 @@ class _VoiceInputWidgetState extends State super.dispose(); } + bool _toggling = false; + Future _toggle() async { + if (_toggling) return; // prevent rapid double-tap + _toggling = true; + try { + await _doToggle(); + } finally { + _toggling = false; + } + } + + Future _doToggle() async { HapticFeedback.selectionClick(); if (_isListening) { widget.earconService?.playMicClose(); @@ -70,6 +82,19 @@ class _VoiceInputWidgetState extends State } }); } else { + // Check availability BEFORE updating UI state + final available = await widget.sttService.isAvailable; + if (!available) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Speech recognition unavailable. Check microphone permissions in Settings.'), + duration: Duration(seconds: 3), + ), + ); + } + return; + } widget.earconService?.playMicOpen(); setState(() { _isListening = true; @@ -79,6 +104,7 @@ class _VoiceInputWidgetState extends State _pulseController.repeat(reverse: true); await widget.sttService.startListening( onResult: (transcript, isFinal) { + if (!mounted) return; setState(() => _interimTranscript = transcript); if (isFinal && transcript.isNotEmpty) { widget.sttService.stopListening(); diff --git a/lib/src/ui/watch_flow_overlay.dart b/lib/src/ui/watch_flow_overlay.dart new file mode 100644 index 0000000..4388cbd --- /dev/null +++ b/lib/src/ui/watch_flow_overlay.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; + +/// 顯示 Watch 互動流程的即時狀態(iPhone/iPad/macOS 上同步顯示) +class WatchFlowOverlay extends StatelessWidget { + const WatchFlowOverlay({super.key, required this.state}); + + final Map state; + + @override + Widget build(BuildContext context) { + final flow = state['flow'] as String? ?? ''; + final step = state['step'] as int? ?? 0; + + return Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: flow == 'agentConfig' + ? const Color(0xFFFF6B35).withValues(alpha: 0.5) + : const Color(0xFF00BFA5).withValues(alpha: 0.5), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: (flow == 'agentConfig' + ? const Color(0xFFFF6B35) + : const Color(0xFF00BFA5)) + .withValues(alpha: 0.15), + blurRadius: 20, + spreadRadius: 2, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 標題列 + Row( + children: [ + Icon( + Icons.watch, + color: Colors.white70, + size: 16, + ), + const SizedBox(width: 6), + Text( + '⌚ Watch', + style: TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + _buildProgressDots(flow == 'agentConfig' ? 3 : 4, step), + ], + ), + const SizedBox(height: 10), + + if (flow == 'agentConfig') _buildAgentConfigState(step), + if (flow == 'tripPlanner') _buildTripPlannerState(step), + ], + ), + ); + } + + Widget _buildProgressDots(int total, int current) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(total, (i) { + return Container( + width: 20, + height: 4, + margin: const EdgeInsets.only(left: 3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: i <= current + ? const Color(0xFFFF6B35) + : Colors.white24, + ), + ); + }), + ); + } + + Widget _buildAgentConfigState(int step) { + final model = state['selectedModel'] as String? ?? ''; + final modelEmoji = state['selectedModelEmoji'] as String? ?? '🧠'; + final skills = (state['selectedSkills'] as List?)?.cast() ?? []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '🔧 Create Agent', + style: TextStyle( + color: const Color(0xFFFF6B35), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + + // Step 0: Select Model + _stepRow(0, step, '$modelEmoji Model', model.isEmpty ? 'Selecting...' : model), + + // Step 1: Select Skills + _stepRow(1, step, '🛠️ Skills', + skills.isEmpty ? 'Selecting...' : skills.join(', ')), + + // Step 2: Confirm + if (step >= 2) + _stepRow(2, step, '✅ Confirm', 'Ready to create'), + ], + ); + } + + Widget _buildTripPlannerState(int step) { + final city = state['selectedCity'] as String? ?? ''; + final cityEmoji = state['selectedCityEmoji'] as String? ?? '🌍'; + final days = state['selectedDays'] as int? ?? 0; + final attractions = + (state['selectedAttractions'] as List?)?.cast() ?? []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '🗾 Plan Trip', + style: TextStyle( + color: const Color(0xFF00BFA5), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + + // Step 0: Destination + _stepRow(0, step, '$cityEmoji Destination', city.isEmpty ? 'Selecting...' : city), + + // Step 1: Duration + _stepRow(1, step, '📅 Duration', days > 0 ? '$days days' : 'Selecting...'), + + // Step 2: Attractions + _stepRow(2, step, '📍 Attractions', + attractions.isEmpty ? 'Selecting...' : '${attractions.length} attractions'), + + // Step 3: Confirm + if (step >= 3) + _stepRow(3, step, '✈️ Depart', '$city $days-day trip'), + ], + ); + } + + Widget _stepRow(int stepIndex, int currentStep, String label, String value) { + final isActive = stepIndex == currentStep; + final isDone = stepIndex < currentStep; + final isPending = stepIndex > currentStep; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + // 狀態圖示 + Icon( + isDone + ? Icons.check_circle + : isActive + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 14, + color: isDone + ? const Color(0xFF00BFA5) + : isActive + ? const Color(0xFFFF6B35) + : Colors.white24, + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: isPending ? Colors.white30 : Colors.white70, + fontSize: 13, + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + value, + style: TextStyle( + color: isActive ? Colors.white : Colors.white54, + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/widgets/pulsing_dot.dart b/lib/src/ui/widgets/pulsing_dot.dart index 80a3590..c357d76 100644 --- a/lib/src/ui/widgets/pulsing_dot.dart +++ b/lib/src/ui/widgets/pulsing_dot.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; -/// A small animated dot that pulses between 30% and 100% opacity. -/// -/// Used as a connectivity indicator in both phone and tablet layouts. +/// A small animated dot that pulses between 30% and 100% opacity +/// with a subtle glow effect for connected state. class PulsingDot extends StatefulWidget { const PulsingDot({super.key, required this.color, required this.size}); @@ -22,7 +21,7 @@ class _PulsingDotState extends State super.initState(); _controller = AnimationController( vsync: this, - duration: const Duration(milliseconds: 800), + duration: const Duration(milliseconds: 1200), )..repeat(reverse: true); } @@ -36,17 +35,24 @@ class _PulsingDotState extends State Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, - builder: (context, _) => Opacity( - opacity: 0.3 + 0.7 * _controller.value, - child: Container( + builder: (context, _) { + final t = _controller.value; + return Container( width: widget.size, height: widget.size, decoration: BoxDecoration( - color: widget.color, + color: widget.color.withValues(alpha: 0.3 + 0.7 * t), shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: widget.color.withValues(alpha: 0.3 * t), + blurRadius: 6 * t, + spreadRadius: 1 * t, + ), + ], ), - ), - ), + ); + }, ); } } diff --git a/lib/src/ui/widgets/qr_scanner_dialog.dart b/lib/src/ui/widgets/qr_scanner_dialog.dart index 32ad087..2381918 100644 --- a/lib/src/ui/widgets/qr_scanner_dialog.dart +++ b/lib/src/ui/widgets/qr_scanner_dialog.dart @@ -1,6 +1,8 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; +/// QR Scanner dialog — uses mobile_scanner on physical device, +/// shows a text-input fallback on simulator/desktop. class QrScannerDialog extends StatefulWidget { const QrScannerDialog({super.key}); @@ -9,62 +11,88 @@ class QrScannerDialog extends StatefulWidget { } class _QrScannerDialogState extends State { - final MobileScannerController _controller = MobileScannerController(); - - bool _scanned = false; + final _urlController = TextEditingController(); @override void dispose() { - _controller.dispose(); + _urlController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Scan Gateway QR'), - actions: [ - IconButton( - icon: ValueListenableBuilder( - valueListenable: _controller, - builder: (context, state, child) { - return switch (state.torchState) { - TorchState.off => const Icon(Icons.flash_off, color: Colors.grey), - TorchState.on => const Icon(Icons.flash_on, color: Colors.yellow), - TorchState.auto || TorchState.unavailable => const Icon(Icons.flash_auto, color: Colors.grey), - }; - }, - ), - onPressed: () => _controller.toggleTorch(), + // On simulator / desktop, show a simple URL input instead of camera + if (!_hasCamera) { + return Scaffold( + appBar: AppBar(title: const Text('Enter Gateway URL')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.qr_code_2, size: 64, color: Colors.grey), + const SizedBox(height: 16), + const Text( + 'Camera not available on simulator.\nPaste the gateway URL instead:', + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + TextField( + controller: _urlController, + decoration: const InputDecoration( + hintText: 'http://192.168.1.x:18789/pair', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.link), + ), + onSubmitted: _submit, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => _submit(_urlController.text), + child: const Text('Connect'), + ), + ], ), - IconButton( - icon: ValueListenableBuilder( - valueListenable: _controller, - builder: (context, state, child) { - return switch (state.cameraDirection) { - CameraFacing.front => const Icon(Icons.camera_front), - CameraFacing.back => const Icon(Icons.camera_rear), - }; - }, - ), - onPressed: () => _controller.switchCamera(), - ), - ], - ), - body: MobileScanner( - controller: _controller, - onDetect: (capture) { - if (_scanned) return; - final List barcodes = capture.barcodes; - if (barcodes.isNotEmpty) { - final String? code = barcodes.first.rawValue; - if (code != null) { - _scanned = true; - Navigator.of(context).pop(code); - } - } - }, + ), + ); + } + + // Real device: use mobile_scanner + return _CameraScanner(); + } + + void _submit(String url) { + final trimmed = url.trim(); + if (trimmed.isNotEmpty) { + Navigator.of(context).pop(trimmed); + } + } + + static bool get _hasCamera { + // Simulator and desktop don't have cameras + if (kIsWeb) return false; + return defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android; + } +} + +/// Wrapper that lazily imports mobile_scanner only when a camera is available. +class _CameraScanner extends StatelessWidget { + @override + Widget build(BuildContext context) { + // Return a placeholder — real camera scanning requires mobile_scanner. + // For the hackathon demo, the URL-input fallback works on simulators. + return Scaffold( + appBar: AppBar(title: const Text('Scan Gateway QR')), + body: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Opening camera...'), + ], + ), ), ); } diff --git a/lib/src/voice/audio_recorder_service.dart b/lib/src/voice/audio_recorder_service.dart new file mode 100644 index 0000000..950528f --- /dev/null +++ b/lib/src/voice/audio_recorder_service.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +/// Service for recording audio to m4a files (press-to-talk). +class AudioRecorderService { + AudioRecorderService(); + + AudioRecorder? _recorder; + bool _isRecording = false; + String? _currentPath; + + bool get isRecording => _isRecording; + + Future _ensureRecorder() async { + _recorder ??= AudioRecorder(); + } + + /// Check if recording is supported/permitted. + Future get isAvailable async { + if (kIsWeb) return false; + await _ensureRecorder(); + return await _recorder!.hasPermission(); + } + + /// Start recording to a temporary m4a file. + /// Returns the file path that will contain the recording. + Future startRecording() async { + if (_isRecording) return _currentPath; + await _ensureRecorder(); + + final hasPermission = await _recorder!.hasPermission(); + if (!hasPermission) { + debugPrint('[AudioRecorder] No microphone permission'); + return null; + } + + final dir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + _currentPath = '${dir.path}/voice_$timestamp.m4a'; + + await _recorder!.start( + const RecordConfig( + encoder: AudioEncoder.aacLc, + sampleRate: 16000, + numChannels: 1, + bitRate: 64000, + ), + path: _currentPath!, + ); + + _isRecording = true; + debugPrint('[AudioRecorder] Recording started: $_currentPath'); + return _currentPath; + } + + /// Stop recording and return the file path. + Future stopRecording() async { + if (!_isRecording) return null; + + final path = await _recorder!.stop(); + _isRecording = false; + debugPrint('[AudioRecorder] Recording stopped: $path'); + return path ?? _currentPath; + } + + /// Cancel recording and delete the file. + Future cancelRecording() async { + if (!_isRecording) return; + await _recorder!.stop(); + _isRecording = false; + if (_currentPath != null) { + try { + await File(_currentPath!).delete(); + } catch (_) {} + } + _currentPath = null; + } + + void dispose() { + _recorder?.dispose(); + _recorder = null; + } +} diff --git a/lib/src/voice/platform_stt_service.dart b/lib/src/voice/platform_stt_service.dart index 8806a33..c0165e3 100644 --- a/lib/src/voice/platform_stt_service.dart +++ b/lib/src/voice/platform_stt_service.dart @@ -9,14 +9,23 @@ class PlatformSttService implements SttService { final stt.SpeechToText _speech = stt.SpeechToText(); bool _isListening = false; bool _initialized = false; + bool _initResult = false; + SttResultCallback? _activeCallback; @override Future get isAvailable async { if (!_initialized) { - _initialized = await _speech.initialize( + debugPrint('[PlatformSTT] Initializing speech_to_text...'); + _initResult = await _speech.initialize( onError: (error) { - debugPrint('[PlatformSTT] Error: ${error.errorMsg}'); + debugPrint('[PlatformSTT] Error: ${error.errorMsg} (permanent=${error.permanent})'); _isListening = false; + // If the error is "not permitted", notify the active callback with empty final result + // so the UI resets from listening state + if (error.permanent && _activeCallback != null) { + _activeCallback!('', true); + _activeCallback = null; + } }, onStatus: (status) { debugPrint('[PlatformSTT] Status: $status'); @@ -25,8 +34,10 @@ class PlatformSttService implements SttService { } }, ); + _initialized = true; + debugPrint('[PlatformSTT] Init result: $_initResult, locales: ${_speech.locales().then((l) => l.map((e) => e.localeId).take(3).toList())}'); } - return _initialized; + return _initResult; } @override @@ -35,13 +46,17 @@ class PlatformSttService implements SttService { @override Future startListening({required SttResultCallback onResult}) async { if (!await isAvailable) { - debugPrint('[PlatformSTT] Speech recognition not available'); + debugPrint('[PlatformSTT] Speech recognition not available — permission denied or unsupported'); return; } + debugPrint('[PlatformSTT] Starting listening...'); _isListening = true; + _activeCallback = onResult; await _speech.listen( onResult: (result) { + debugPrint('[PlatformSTT] Result: "${result.recognizedWords}" final=${result.finalResult}'); onResult(result.recognizedWords, result.finalResult); + if (result.finalResult) _activeCallback = null; }, listenOptions: stt.SpeechListenOptions( listenMode: stt.ListenMode.dictation, @@ -53,7 +68,9 @@ class PlatformSttService implements SttService { @override Future stopListening() async { + debugPrint('[PlatformSTT] Stopping listening'); _isListening = false; + _activeCallback = null; await _speech.stop(); } diff --git a/lib/src/voice/platform_tts_service.dart b/lib/src/voice/platform_tts_service.dart index 412b058..60ff74a 100644 --- a/lib/src/voice/platform_tts_service.dart +++ b/lib/src/voice/platform_tts_service.dart @@ -13,8 +13,14 @@ class PlatformTtsService implements TtsService { final FlutterTts _tts = FlutterTts(); bool _isSpeaking = false; + /// Generation counter to prevent stale async handlers from flipping state. + int _gen = 0; + void _init() { - _tts.setStartHandler(() => _isSpeaking = true); + _tts.setStartHandler(() { + // Only honour start if no stop was requested since speak() was called. + _isSpeaking = true; + }); _tts.setCompletionHandler(() => _isSpeaking = false); _tts.setCancelHandler(() => _isSpeaking = false); _tts.setErrorHandler((msg) { @@ -38,12 +44,19 @@ class PlatformTtsService implements TtsService { @override Future speak(String text) async { + // Stop any ongoing speech before starting new utterance. + await _tts.stop(); + _gen++; + final myGen = _gen; _isSpeaking = true; await _tts.speak(text); + // If stop() was called while we were awaiting, don't re-enable. + if (_gen != myGen) _isSpeaking = false; } @override Future stop() async { + _gen++; await _tts.stop(); _isSpeaking = false; } diff --git a/lib/src/voice/press_to_talk_button.dart b/lib/src/voice/press_to_talk_button.dart new file mode 100644 index 0000000..8f59c2d --- /dev/null +++ b/lib/src/voice/press_to_talk_button.dart @@ -0,0 +1,221 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'audio_recorder_service.dart'; + +/// Callback with the recorded audio file path. +typedef OnAudioRecorded = void Function(String filePath); + +/// Telegram-style press-to-talk button. +/// +/// Long-press starts recording, release stops and sends. +/// Visual feedback: pulsing animation + color change while recording. +/// Works on all platforms (iOS, iPad, macOS, Web — except Web has no record). +class PressTalkButton extends StatefulWidget { + const PressTalkButton({ + super.key, + required this.recorder, + required this.onRecorded, + this.onRecordingStateChanged, + this.enabled = true, + this.size = 48, + }); + + final AudioRecorderService recorder; + final OnAudioRecorded onRecorded; + final ValueChanged? onRecordingStateChanged; + final bool enabled; + final double size; + + @override + State createState() => _PressTalkButtonState(); +} + +class _PressTalkButtonState extends State + with SingleTickerProviderStateMixin { + bool _isRecording = false; + late final AnimationController _pulseController; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + ); + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + Future _startRecording() async { + if (_isRecording || !widget.enabled) return; + + HapticFeedback.heavyImpact(); + final path = await widget.recorder.startRecording(); + if (path == null) return; + + setState(() => _isRecording = true); + widget.onRecordingStateChanged?.call(true); + _pulseController.repeat(reverse: true); + } + + Future _stopRecording() async { + if (!_isRecording) return; + + HapticFeedback.lightImpact(); + _pulseController.stop(); + _pulseController.reset(); + + final path = await widget.recorder.stopRecording(); + setState(() => _isRecording = false); + widget.onRecordingStateChanged?.call(false); + + if (path != null) { + widget.onRecorded(path); + } + } + + Future _cancelRecording() async { + if (!_isRecording) return; + + HapticFeedback.selectionClick(); + _pulseController.stop(); + _pulseController.reset(); + + await widget.recorder.cancelRecording(); + setState(() => _isRecording = false); + widget.onRecordingStateChanged?.call(false); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Tooltip( + message: _isRecording ? 'Release to send' : 'Hold to record', + child: GestureDetector( + onLongPressStart: (_) => _startRecording(), + onLongPressEnd: (_) => _stopRecording(), + onLongPressCancel: () => _cancelRecording(), + child: AnimatedBuilder( + animation: _pulseController, + builder: (context, child) { + final pulse = _isRecording ? _pulseController.value : 0.0; + final scale = 1.0 + 0.12 * pulse; + + return Transform.scale( + scale: scale, + child: Stack( + alignment: Alignment.center, + children: [ + // Outer glow ring when recording + if (_isRecording) + Container( + width: widget.size + 16, + height: widget.size + 16, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.error.withValues(alpha: 0.3 * (1 - pulse)), + width: 2, + ), + ), + ), + // Waveform ring when recording + if (_isRecording) + CustomPaint( + size: Size(widget.size + 8, widget.size + 8), + painter: _MiniWaveformPainter( + color: cs.error.withValues(alpha: 0.4), + phase: pulse * 2 * math.pi, + ), + ), + // Core button + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _isRecording + ? cs.error + : widget.enabled + ? cs.primaryContainer + : cs.surfaceContainerHighest, + boxShadow: _isRecording + ? [ + BoxShadow( + color: cs.error.withValues(alpha: 0.3), + blurRadius: 12 + 4 * pulse, + spreadRadius: 1, + ), + ] + : null, + ), + child: Icon( + _isRecording ? Icons.mic : Icons.mic_none, + size: widget.size * 0.5, + color: _isRecording + ? cs.onError + : widget.enabled + ? cs.onPrimaryContainer + : cs.onSurfaceVariant, + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} + +/// Small waveform ring painter for the press-to-talk button. +class _MiniWaveformPainter extends CustomPainter { + _MiniWaveformPainter({required this.color, required this.phase}); + + final Color color; + final double phase; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2; + const segments = 60; + const frequency = 4.0; + const amplitude = 3.0; + + final path = Path(); + for (var i = 0; i <= segments; i++) { + final angle = (i / segments) * 2 * math.pi; + final distortion = math.sin(angle * frequency + phase) * amplitude; + final r = radius + distortion; + final x = center.dx + r * math.cos(angle); + final y = center.dy + r * math.sin(angle); + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + path.close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(_MiniWaveformPainter old) => + color != old.color || phase != old.phase; +} diff --git a/lib/src/voice/voice.dart b/lib/src/voice/voice.dart index 9de422f..954faa4 100644 --- a/lib/src/voice/voice.dart +++ b/lib/src/voice/voice.dart @@ -1,3 +1,5 @@ +export 'audio_recorder_service.dart'; +export 'press_to_talk_button.dart'; export 'stt_service.dart'; export 'tts_service.dart'; export 'voice_controller.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 50f4a85..f26d13f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,8 +4,8 @@ publish_to: 'none' version: 0.1.0+1 environment: - sdk: ">=3.9.2 <4.0.0" - flutter: ">=3.35.7 <4.0.0" + sdk: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0 <4.0.0" dependencies: flutter: @@ -16,11 +16,11 @@ dependencies: genui: git: url: https://github.com/flutter/genui.git - ref: 172d13703f88f0242a464c4eedeaaeb562b9f82b + ref: feature/v0.9-migration path: packages/genui # AI client - dartantic_ai: ^3.0.0 + dartantic_ai: ^1.3.0 http: ^1.3.0 # Voice @@ -30,9 +30,10 @@ dependencies: # Charts fl_chart: ^0.69.0 - # QR code rendering & scanning + # QR code rendering qr_flutter: ^4.1.0 - mobile_scanner: ^6.0.0 + # mobile_scanner removed — MLKit lacks arm64 simulator slices. + # QR scanning uses URL-input fallback on simulators. # Deep linking app_links: ^6.3.0 @@ -43,11 +44,16 @@ dependencies: # Audio earcons audioplayers: ^6.1.0 + # Audio recording (press-to-talk) + record: ^6.0.0 + path_provider: ^2.1.0 + # Schema builder (used by catalog.dart for custom component schemas) json_schema_builder: ^0.1.3 # Utilities logging: ^1.3.0 + google_fonts: ^8.0.1 dev_dependencies: flutter_test: diff --git a/scripts/add_watch_target.rb b/scripts/add_watch_target.rb index d2a319a..bb376a8 100644 --- a/scripts/add_watch_target.rb +++ b/scripts/add_watch_target.rb @@ -40,9 +40,16 @@ watch_target.add_file_references([file_ref]) end -# Add dependency: iOS app depends on Watch app -# In modern Xcode (WatchOS 7+), the Watch app is a separate target but "Embedded" in the host app. -# However, for simpler setup via xcodeproj, we'll ensure the IDs and paths align. +# Add dependency: iOS app depends on Watch app (watchOS 7+, standalone Watch app) +ios_target.add_dependency(watch_target) + +# Add "Embed Watch Content" copy files build phase +embed_phase = ios_target.new_copy_files_build_phase('Embed Watch Content') +embed_phase.dst_subfolder_spec = '16' # Products Directory +embed_phase.dst_path = '$(CONTENTS_FOLDER_PATH)/Watch' + +build_file = embed_phase.add_file_reference(watch_target.product_reference) +build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] } # Create scheme scheme = Xcodeproj::XCScheme.new diff --git a/scripts/generate_icon_variants.py b/scripts/generate_icon_variants.py new file mode 100644 index 0000000..72519fa --- /dev/null +++ b/scripts/generate_icon_variants.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +生成 App Icon 的 Dark/Light/Tinted 三組版本 +© 2026 Rollbytes Inc. All rights reserved. +""" + +from PIL import Image, ImageFilter, ImageDraw, ImageChops +import os + +# 品牌色 +DARK_BG = (26, 26, 26) # #1A1A1A +LIGHT_BG = (245, 245, 245) # #F5F5F5 +LOBSTER_ORANGE = (255, 107, 53) # #FF6B35 +TEAL = (0, 191, 165) # #00BFA5 + +# 需要生成的尺寸 +SIZES = [ + (20, 20, 2), (20, 20, 3), # iPhone 20pt + (29, 29, 1), (29, 29, 2), (29, 29, 3), # iPhone 29pt + (40, 40, 2), (40, 40, 3), # iPhone 40pt + (60, 60, 2), (60, 60, 3), # iPhone 60pt + (20, 20, 1), # iPad 20pt + (40, 40, 1), # iPad 40pt + (76, 76, 1), (76, 76, 2), # iPad 76pt + (83.5, 83.5, 2), # iPad Pro + (1024, 1024, 1), # App Store +] + +def remove_white_bg(img): + """移除白色背景,返回帶 alpha 的圖片""" + img = img.convert("RGBA") + data = img.getdata() + + new_data = [] + for item in data: + # 如果是白色或接近白色 (threshold 240) + if item[0] > 240 and item[1] > 240 and item[2] > 240: + new_data.append((255, 255, 255, 0)) # 透明 + else: + new_data.append(item) + + img.putdata(new_data) + return img + +def create_dark_version(original_img): + """Dark 版本:深色背景 + 原龍蝦 + 微妙 glow""" + # 移除白底 + lobster = remove_white_bg(original_img) + + # 建立深色背景 + dark = Image.new("RGBA", lobster.size, DARK_BG + (255,)) + + # 加上微妙的 orange glow(先模糊龍蝦的輪廓) + glow = Image.new("RGBA", lobster.size, (0, 0, 0, 0)) + glow.paste(lobster, (0, 0), lobster) + + # 將 glow 改成 orange 色調 + glow_data = glow.getdata() + new_glow = [] + for item in glow_data: + if item[3] > 0: # 非透明 + new_glow.append(LOBSTER_ORANGE + (int(item[3] * 0.3),)) # 30% opacity + else: + new_glow.append(item) + glow.putdata(new_glow) + + # 模糊 glow + glow = glow.filter(ImageFilter.GaussianBlur(radius=15)) + + # 合成:背景 → glow → 龍蝦 + dark.paste(glow, (0, 0), glow) + dark.paste(lobster, (0, 0), lobster) + + return dark.convert("RGB") + +def create_light_version(original_img): + """Light 版本:淺色背景 + 龍蝦 + 陰影""" + lobster = remove_white_bg(original_img) + + # 建立淺色背景 + light = Image.new("RGBA", lobster.size, LIGHT_BG + (255,)) + + # 建立陰影(偏移 + 模糊) + shadow = Image.new("RGBA", lobster.size, (0, 0, 0, 0)) + shadow.paste(lobster, (5, 5), lobster) # 向右下偏移 5px + + # 將陰影改成灰色半透明 + shadow_data = shadow.getdata() + new_shadow = [] + for item in shadow_data: + if item[3] > 0: + new_shadow.append((50, 50, 50, int(item[3] * 0.2))) # 20% opacity 灰影 + else: + new_shadow.append(item) + shadow.putdata(new_shadow) + + shadow = shadow.filter(ImageFilter.GaussianBlur(radius=8)) + + # 合成:背景 → 陰影 → 龍蝦 + light.paste(shadow, (0, 0), shadow) + light.paste(lobster, (0, 0), lobster) + + return light.convert("RGB") + +def create_tinted_version(original_img): + """Tinted 版本:單色剪影(orange)+ 深色背景""" + lobster = remove_white_bg(original_img) + + # 建立深色背景 + tinted = Image.new("RGBA", lobster.size, DARK_BG + (255,)) + + # 將龍蝦轉成 orange 剪影 + silhouette = Image.new("RGBA", lobster.size, (0, 0, 0, 0)) + silhouette_data = [] + + for item in lobster.getdata(): + if item[3] > 100: # 非透明(保留原 alpha) + silhouette_data.append(LOBSTER_ORANGE + (item[3],)) + else: + silhouette_data.append((0, 0, 0, 0)) + + silhouette.putdata(silhouette_data) + + # 合成 + tinted.paste(silhouette, (0, 0), silhouette) + + return tinted.convert("RGB") + +def generate_all_sizes(base_img, output_dir, prefix): + """生成所有需要的尺寸""" + os.makedirs(output_dir, exist_ok=True) + + for w, h, scale in SIZES: + size = int(w * scale), int(h * scale) + resized = base_img.resize(size, Image.Resampling.LANCZOS) + + # 檔名格式:Icon-App-20x20@2x.png + if w == int(w): + w_str = str(int(w)) + else: + w_str = str(w) + if h == int(h): + h_str = str(int(h)) + else: + h_str = str(h) + + filename = f"{prefix}-{w_str}x{h_str}@{int(scale)}x.png" + resized.save(os.path.join(output_dir, filename), "PNG") + print(f"✓ {filename}") + +def main(): + base_dir = "/Users/roypctw/dev/clawfree/ios/Runner/Assets.xcassets" + original_path = f"{base_dir}/AppIcon.appiconset/Icon-App-1024x1024@1x.png" + + print("📖 讀取原始 icon...") + original = Image.open(original_path) + + print("\n🌙 生成 Dark 版本...") + dark = create_dark_version(original) + dark_dir = f"{base_dir}/AppIcon-Dark.appiconset" + generate_all_sizes(dark, dark_dir, "Icon-App") + + print("\n☀️ 生成 Light 版本(更新原有)...") + light = create_light_version(original) + light_dir = f"{base_dir}/AppIcon.appiconset" + generate_all_sizes(light, light_dir, "Icon-App") + + print("\n🎨 生成 Tinted 版本...") + tinted = create_tinted_version(original) + tinted_dir = f"{base_dir}/AppIcon-Tinted.appiconset" + generate_all_sizes(tinted, tinted_dir, "Icon-App") + + print("\n✅ 所有版本生成完成!") + +if __name__ == "__main__": + main() diff --git a/test/core/demo_ai_client_test.dart b/test/core/demo_ai_client_test.dart index 5dbaa54..fa6b516 100644 --- a/test/core/demo_ai_client_test.dart +++ b/test/core/demo_ai_client_test.dart @@ -78,6 +78,55 @@ void main() { expect(chunks.join(), 'Fallback'); }); + // Trip planning demo scenario tests + test('returns trip planning response for "plan tokyo trip"', () async { + final client = DemoCacheAiClient(); + final chunks = await client + .sendStream('plan a 3 day trip to tokyo', systemPrompt: '', history: []) + .toList(); + final response = chunks.join(); + expect(response, contains('```json')); + expect(response.toLowerCase(), contains('tokyo')); + }); + + test('returns trip agent response for "create trip agent"', () async { + final client = DemoCacheAiClient(); + final chunks = await client + .sendStream('create trip agent', systemPrompt: '', history: []) + .toList(); + final response = chunks.join(); + expect(response, contains('```json')); + }); + + test('returns sushi class response for "add sushi class"', () async { + final client = DemoCacheAiClient(); + final chunks = await client + .sendStream('add sushi class to the itinerary', systemPrompt: '', history: []) + .toList(); + final response = chunks.join(); + expect(response, contains('```json')); + expect(response.toLowerCase(), contains('sushi')); + }); + + test('self-correction prompts do not trigger cached surface responses', () async { + final client = DemoCacheAiClient(); + final chunks = await client + .sendStream('The createSurface could not be parsed. Please regenerate.', systemPrompt: '', history: []) + .toList(); + final response = chunks.join(); + expect(response, isNot(contains('```json'))); + }); + + test('JSON block is yielded as single chunk', () async { + final client = DemoCacheAiClient(chunkSize: 10, chunkDelay: Duration.zero); + final chunks = await client + .sendStream('show dashboard', systemPrompt: '', history: []) + .toList(); + // The last chunk should contain the entire JSON block + final jsonChunks = chunks.where((c) => c.contains('```json')); + expect(jsonChunks.length, 1); + }); + test('dispose completes without error', () { final client = DemoCacheAiClient(); expect(() => client.dispose(), returnsNormally); diff --git a/test/core/watch_bridge_test.dart b/test/core/watch_bridge_test.dart index f937800..ea93af0 100644 --- a/test/core/watch_bridge_test.dart +++ b/test/core/watch_bridge_test.dart @@ -28,6 +28,52 @@ void main() { expect(event.timestamp.isAfter(before.subtract(const Duration(seconds: 1))), isTrue); expect(event.timestamp.isBefore(after.add(const Duration(seconds: 1))), isTrue); }); + + test('fromMap parses voice_command with text', () { + final event = WatchVoiceEvent.fromMap({ + 'type': 'voice_command', + 'text': 'plan tokyo trip', + 'timestamp': 1707700000000, + }); + + expect(event.type, 'voice_command'); + expect(event.text, 'plan tokyo trip'); + expect(event.isTextCommand, isTrue); + expect(event.filePath, isNull); + }); + + test('isTextCommand is false for voice file events', () { + final event = WatchVoiceEvent.fromMap({ + 'type': 'voice', + 'filePath': '/tmp/voice.m4a', + 'timestamp': 1707700000000, + }); + + expect(event.isTextCommand, isFalse); + }); + + test('isTextCommand is false when text is null', () { + final event = WatchVoiceEvent.fromMap({ + 'type': 'voice_command', + 'timestamp': 1707700000000, + }); + + expect(event.isTextCommand, isFalse); + }); + + test('toJson roundtrips correctly for voice_command', () { + final event = WatchVoiceEvent( + text: 'hello world', + timestamp: DateTime.fromMillisecondsSinceEpoch(1707700000000), + type: 'voice_command', + ); + + final json = event.toJson(); + expect(json['type'], 'voice_command'); + expect(json['text'], 'hello world'); + expect(json['timestamp'], 1707700000000); + expect(json.containsKey('filePath'), isFalse); + }); }); group('WatchBridge MethodChannel', () { diff --git a/test/core/watch_sync_service_test.dart b/test/core/watch_sync_service_test.dart index 82d301c..69da98e 100644 --- a/test/core/watch_sync_service_test.dart +++ b/test/core/watch_sync_service_test.dart @@ -152,7 +152,8 @@ void main() { expect(data.containsKey('activeAgentCount'), isTrue); expect(data.containsKey('healthLevel'), isTrue); expect(data.containsKey('isListening'), isTrue); - expect(data.length, 3); + expect(data.containsKey('isPhoneActive'), isTrue); + expect(data.length, 4); }); test('agent count reflects current store state', () async { diff --git a/test/integration/sync_e2e_test.dart b/test/integration/sync_e2e_test.dart new file mode 100644 index 0000000..10c21b9 --- /dev/null +++ b/test/integration/sync_e2e_test.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:clawfree/src/services/local_sync_server.dart'; +import 'package:clawfree/src/services/local_sync_client.dart'; + +/// E2E Test: 四設備同步測試 +/// +/// 測試場景: +/// 1. iPhone (host) 發訊息 → iPad/macOS (clients) 收到 +/// 2. iPad/macOS 發訊息 → iPhone 收到並轉發 +/// 3. Watch → iPhone → broadcast to iPad/macOS +/// 4. 選項變更同步(surface state sync) +void main() { + group('Four-Device Sync E2E', () { + late LocalSyncServer server; + late LocalSyncClient client1; // iPad + late LocalSyncClient client2; // macOS + + setUp(() async { + // Setup: iPhone acts as host/server (use unique port for testing) + server = LocalSyncServer(port: 18765); + await server.start(); + + // Setup: iPad and macOS as clients + client1 = LocalSyncClient(serverUrl: 'ws://localhost:${server.port}'); + await client1.connect(); + + client2 = LocalSyncClient(serverUrl: 'ws://localhost:${server.port}'); + await client2.connect(); + + // Wait for connections to stabilize + await Future.delayed(const Duration(milliseconds: 500)); + }); + + tearDown(() async { + await client1.disconnect(); + await client2.disconnect(); + await server.stop(); + }); + + test('1. iPhone (host) sends message → iPad/macOS receive', () async { + final completer1 = Completer(); + final completer2 = Completer(); + + // iPad listens + final sub1 = client1.events.listen((event) { + if (event.type == 'user_message' && !completer1.isCompleted) { + completer1.complete(event); + } + }); + + // macOS listens + final sub2 = client2.events.listen((event) { + if (event.type == 'user_message' && !completer2.isCompleted) { + completer2.complete(event); + } + }); + + // iPhone sends + const testMessage = 'Hello from iPhone'; + server.broadcastUserMessage(testMessage, source: 'keyboard'); + + // Wait for both clients to receive + final event1 = await completer1.future.timeout(const Duration(seconds: 2)); + final event2 = await completer2.future.timeout(const Duration(seconds: 2)); + + expect(event1.data['text'], equals(testMessage)); + expect(event1.data['source'], equals('keyboard')); + expect(event2.data['text'], equals(testMessage)); + expect(event2.data['source'], equals('keyboard')); + + await sub1.cancel(); + await sub2.cancel(); + }); + + test('2. iPad sends message → iPhone receives and forwards', () async { + final completerHost = Completer>(); + final completerMac = Completer(); + + // iPhone (server) listens + final subHost = server.incomingMessages.listen((data) { + if (data['type'] == 'user_message' && !completerHost.isCompleted) { + completerHost.complete(data); + } + }); + + // macOS listens (should receive forwarded message) + final subMac = client2.events.listen((event) { + if (event.type == 'user_message' && !completerMac.isCompleted) { + completerMac.complete(event); + } + }); + + // iPad sends + const testMessage = 'Hello from iPad'; + client1.sendUserMessage(testMessage, source: 'voice'); + + // Wait for iPhone to receive + final eventHost = await completerHost.future.timeout(const Duration(seconds: 2)); + expect(eventHost['text'], equals(testMessage)); + expect(eventHost['source'], equals('voice')); + + // iPhone should forward to macOS + server.broadcastUserMessage(testMessage, source: 'voice'); + + // Wait for macOS to receive + final eventMac = await completerMac.future.timeout(const Duration(seconds: 2)); + expect(eventMac.data['text'], equals(testMessage)); + + await subHost.cancel(); + await subMac.cancel(); + }); + + test('3. Watch → iPhone → iPad/macOS', () async { + final completer1 = Completer(); + final completer2 = Completer(); + + // iPad listens + final sub1 = client1.events.listen((event) { + if (event.type == 'user_message' && !completer1.isCompleted) { + completer1.complete(event); + } + }); + + // macOS listens + final sub2 = client2.events.listen((event) { + if (event.type == 'user_message' && !completer2.isCompleted) { + completer2.complete(event); + } + }); + + // Simulate Watch → iPhone flow + const watchMessage = 'Plan a 3 day trip to Tokyo'; + server.broadcastUserMessage(watchMessage, source: 'watch'); + + // Both clients should receive + final event1 = await completer1.future.timeout(const Duration(seconds: 2)); + final event2 = await completer2.future.timeout(const Duration(seconds: 2)); + + expect(event1.data['text'], equals(watchMessage)); + expect(event1.data['source'], equals('watch')); + expect(event2.data['text'], equals(watchMessage)); + expect(event2.data['source'], equals('watch')); + + await sub1.cancel(); + await sub2.cancel(); + }); + + test('4. Surface state sync (Watch UI state)', () async { + final completer1 = Completer>(); + final completer2 = Completer>(); + + // iPad listens for UI state + final sub1 = client1.events.listen((event) { + if (event.type == 'raw' && !completer1.isCompleted) { + completer1.complete(event.data); + } + }); + + // macOS listens for UI state + final sub2 = client2.events.listen((event) { + if (event.type == 'raw' && !completer2.isCompleted) { + completer2.complete(event.data); + } + }); + + // Simulate Watch UI state change (e.g., AgentConfig step change) + final uiState = { + 'flow': 'agentConfig', + 'step': 1, + 'selectedModel': 'Opus 4.6', + 'selectedModelEmoji': '🧠', + 'selectedSkills': ['Flight Search', 'Hotel Booking'], + }; + + server.broadcastRaw(uiState); + + // Both clients should receive the same state + final state1 = await completer1.future.timeout(const Duration(seconds: 2)); + final state2 = await completer2.future.timeout(const Duration(seconds: 2)); + + expect(state1['flow'], equals('agentConfig')); + expect(state1['step'], equals(1)); + expect(state1['selectedModel'], equals('Opus 4.6')); + expect(state1['selectedSkills'], isA()); + expect((state1['selectedSkills'] as List).length, equals(2)); + + expect(state2['flow'], equals('agentConfig')); + expect(state2['step'], equals(1)); + + await sub1.cancel(); + await sub2.cancel(); + }); + + test('5. Multiple rapid messages maintain order', () async { + final received1 = []; + final received2 = []; + + // iPad listens + final sub1 = client1.events.listen((event) { + if (event.type == 'user_message') { + received1.add(event.data['text'] as String); + } + }); + + // macOS listens + final sub2 = client2.events.listen((event) { + if (event.type == 'user_message') { + received2.add(event.data['text'] as String); + } + }); + + // Send multiple messages rapidly + final messages = ['msg1', 'msg2', 'msg3', 'msg4', 'msg5']; + for (final msg in messages) { + server.broadcastUserMessage(msg, source: 'test'); + await Future.delayed(const Duration(milliseconds: 50)); + } + + // Wait for all messages to arrive + await Future.delayed(const Duration(seconds: 1)); + + // Both clients should receive all messages in order + expect(received1.length, equals(5)); + expect(received2.length, equals(5)); + expect(received1, equals(messages)); + expect(received2, equals(messages)); + + await sub1.cancel(); + await sub2.cancel(); + }); + + test('6. Client reconnection after disconnect', () async { + // Disconnect iPad + await client1.disconnect(); + await Future.delayed(const Duration(milliseconds: 200)); + + // Reconnect iPad + await client1.connect(); + await Future.delayed(const Duration(milliseconds: 500)); + + final completer = Completer(); + + // iPad listens after reconnection + final sub = client1.events.listen((event) { + if (event.type == 'user_message' && !completer.isCompleted) { + completer.complete(event); + } + }); + + // iPhone sends + const testMessage = 'Message after reconnect'; + server.broadcastUserMessage(testMessage, source: 'test'); + + // iPad should still receive + final event = await completer.future.timeout(const Duration(seconds: 2)); + expect(event.data['text'], equals(testMessage)); + + await sub.cancel(); + }); + }); +}