diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b242572e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "githubPullRequests.ignoredPullRequestBranches": [ + "main" + ] +} \ No newline at end of file diff --git a/apps/media-discovery/package-lock.json b/apps/media-discovery/package-lock.json index e476adba..153bc78d 100644 --- a/apps/media-discovery/package-lock.json +++ b/apps/media-discovery/package-lock.json @@ -186,7 +186,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2182,15 +2181,6 @@ "@ruvector/attention-win32-x64-msvc": "0.1.3" } }, - "node_modules/@ruvector/attention/node_modules/@ruvector/attention-darwin-x64": { - "optional": true - }, - "node_modules/@ruvector/attention/node_modules/@ruvector/attention-linux-x64-gnu": { - "optional": true - }, - "node_modules/@ruvector/attention/node_modules/@ruvector/attention-win32-x64-msvc": { - "optional": true - }, "node_modules/@ruvector/core": { "version": "0.1.17", "resolved": "https://registry.npmjs.org/@ruvector/core/-/core-0.1.17.tgz", @@ -2237,24 +2227,6 @@ "node": ">= 10" } }, - "node_modules/@ruvector/gnn/node_modules/@ruvector/gnn-darwin-arm64": { - "optional": true - }, - "node_modules/@ruvector/gnn/node_modules/@ruvector/gnn-darwin-x64": { - "optional": true - }, - "node_modules/@ruvector/gnn/node_modules/@ruvector/gnn-linux-arm64-gnu": { - "optional": true - }, - "node_modules/@ruvector/gnn/node_modules/@ruvector/gnn-linux-arm64-musl": { - "optional": true - }, - "node_modules/@ruvector/gnn/node_modules/@ruvector/gnn-linux-x64-musl": { - "optional": true - }, - "node_modules/@ruvector/gnn/node_modules/@ruvector/gnn-win32-x64-msvc": { - "optional": true - }, "node_modules/@ruvector/sona": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@ruvector/sona/-/sona-0.1.4.tgz", @@ -2488,7 +2460,6 @@ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2499,7 +2470,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2560,7 +2530,6 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -3194,7 +3163,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3690,7 +3658,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4536,7 +4503,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4710,7 +4676,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5979,7 +5944,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6863,7 +6827,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7065,7 +7028,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7075,7 +7037,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8112,7 +8073,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8236,7 +8196,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -8348,7 +8307,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8480,7 +8438,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9236,7 +9193,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/apps/media-discovery/src/lib/natural-language-search.ts b/apps/media-discovery/src/lib/natural-language-search.ts index 48e9d697..bf88a272 100644 --- a/apps/media-discovery/src/lib/natural-language-search.ts +++ b/apps/media-discovery/src/lib/natural-language-search.ts @@ -136,13 +136,22 @@ export async function semanticSearch( query: string, userPreferences?: number[] ): Promise { - // Parse the natural language query - const semanticQuery = await parseSearchQuery(query); + // Parse the natural language query (with fallback) + let semanticQuery: SemanticSearchQuery; + try { + semanticQuery = await parseSearchQuery(query); + } catch (error) { + console.warn('AI parsing failed, using basic query:', error); + semanticQuery = { query }; + } - // Parallel search strategies + // Parallel search strategies with graceful degradation const [tmdbResults, vectorResults] = await Promise.all([ performTMDBSearch(semanticQuery), - performVectorSearch(semanticQuery), + performVectorSearch(semanticQuery).catch(err => { + console.warn('Vector search failed, continuing with TMDB only:', err); + return []; + }), ]); // Merge and rank results diff --git a/apps/vibecheck-ios/.gitignore b/apps/vibecheck-ios/.gitignore new file mode 100644 index 00000000..b903f9d7 --- /dev/null +++ b/apps/vibecheck-ios/.gitignore @@ -0,0 +1,20 @@ +# Xcode user-specific files +*.xcuserstate +xcuserdata/ + +# Build logs +build*.txt +build*.log +*.log + +# Test scripts (local only) +test_*.sh +verify_*.sh + +# Test runners (compiled) +*TestRunner + +# OS generated +.DS_Store +.build/ +Package.resolved diff --git a/apps/vibecheck-ios/Package.swift b/apps/vibecheck-ios/Package.swift new file mode 100644 index 00000000..cd1cef75 --- /dev/null +++ b/apps/vibecheck-ios/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "VibeCheck", + platforms: [ + .iOS(.v17) + ], + dependencies: [ + .package(url: "https://github.com/swiftwasm/WasmKit", from: "0.1.0") + ], + targets: [ + .target( + name: "VibeCheck", + dependencies: [ + .product(name: "WasmKit", package: "WasmKit") + ] + ) + ] +) diff --git a/apps/vibecheck-ios/README.md b/apps/vibecheck-ios/README.md new file mode 100644 index 00000000..f0e5d660 --- /dev/null +++ b/apps/vibecheck-ios/README.md @@ -0,0 +1,155 @@ +# VibeCheck - Private Media Recommendations + +**Your mood. Your data. Your recommendations.** + +VibeCheck is a privacy-first iOS app that uses your Apple Health data to recommend movies and TV shows based on how you actually feel—not what algorithms think you should watch. + +## The Problem + +Every night, millions spend up to 45 minutes deciding what to watch. Netflix knows what you watched, but not how you feel. Your streaming platform optimizes for *their* retention, not *your* experience. + +## The Solution + +VibeCheck flips the model: + +- **Your health data stays on YOUR device** - never uploaded, never shared +- **Real biometric signals** inform recommendations - HRV, sleep, activity +- **You own your preference profile** - export it, take it anywhere +- **Cross-platform recommendations** - not locked to one streaming service + +## Features + +### Mood Detection +- Heart Rate Variability → Stress level estimation +- Sleep data → Energy level assessment +- Step count → Activity classification +- Time of day → Context awareness + +### Smart Recommendations +- Mood-based filtering: "comfort", "light", "engaging", "exciting", "calming" +- Genre preferences you control +- Runtime awareness (short episodes when you're tired) +- Platform availability (shows what's on YOUR subscriptions) + +### Privacy by Design +- 100% on-device processing +- No accounts, no cloud, no tracking +- Export your data anytime (JSON) +- Revoke Health access in Settings + +### Modern iOS UI +- iOS 18 Mesh Gradients for mood visualization +- Animated VibeRing mood indicator +- Interactive home screen widgets +- Scroll transitions and haptic feedback + +## Requirements + +- iOS 26 beta +- iPhone 12 Pro Max (or newer) with Apple Health +- Apple Watch recommended for HRV data +- Xcode 16+ with iOS 26 SDK (for building) + +## Getting Started + +1. Open `VibeCheck.xcodeproj` in Xcode +2. Select your development team in Signing & Capabilities +3. Build and run on a real device (HealthKit not available in Simulator) +4. Grant Health access when prompted +5. Check your vibe and get recommendations! + +## Project Structure + +``` +VibeCheck/ +├── App/ +│ ├── VibeCheckApp.swift # App entry point +│ └── ContentView.swift # Tab navigation +├── Models/ +│ ├── MoodState.swift # Mood classification model +│ └── MediaItem.swift # Media content model +├── Data/ +│ ├── HealthKitManager.swift # Health data access +│ └── LocalStore.swift # SwiftData persistence +├── Engine/ +│ ├── MoodClassifier.swift # Biometric → Mood logic +│ └── RecommendationEngine.swift # Mood → Recommendations +├── Views/ +│ ├── ForYouView.swift # Main recommendations screen +│ ├── VibeCheckView.swift # Health data dashboard +│ ├── WatchlistView.swift # Saved items +│ ├── SettingsView.swift # Preferences & privacy +│ └── Components/ +│ ├── MoodMeshBackground.swift +│ ├── VibeRing.swift +│ ├── RecommendationCard.swift +│ ├── QuickMoodOverride.swift +│ └── VibeHeader.swift +├── Widget/ +│ └── VibeWidget.swift # Home screen widget +└── Resources/ + ├── Info.plist + └── VibeCheck.entitlements +``` + +## How Mood Classification Works + +### Energy Level +Based on sleep hours and activity: +- **Exhausted**: <5 hours sleep, sedentary day +- **Low**: 5-6 hours sleep +- **Moderate**: 6-7 hours sleep, some activity +- **High**: 7-8 hours sleep, active day +- **Wired**: High activity, moderate sleep + +### Stress Level +Based on Heart Rate Variability (HRV): +- **Relaxed**: HRV > 70ms +- **Calm**: HRV 50-70ms +- **Neutral**: HRV 35-50ms +- **Tense**: HRV 20-35ms +- **Stressed**: HRV < 20ms + +### Recommendation Hints +Mood combinations map to content types: +- **Comfort**: Feel-good shows, familiar rewatches (exhausted + any stress) +- **Gentle**: Low-intensity, no thrillers (low energy + calm) +- **Light**: Comedies, short episodes (any energy + high stress) +- **Engaging**: Moderate intensity (moderate energy + relaxed) +- **Exciting**: Action, adventure (high energy + relaxed) +- **Calming**: Documentaries, slow pacing (wired/any stress) + +## Privacy + +VibeCheck is built on a simple principle: **your health data is yours**. + +- All processing happens on your iPhone +- We use Apple's HealthKit APIs with read-only access +- No data is ever sent to any server +- You can export all your preferences as JSON +- Deleting the app deletes all your data + +Read more in Settings → Privacy. + +## Built For + +**Agentics Foundation TV5 Hackathon** +Track: Entertainment Discovery +Theme: Solve the 45-minute decision problem + +## Technologies + +- SwiftUI (iOS 18) +- SwiftData +- HealthKit +- WidgetKit +- SF Symbols 6 + +## License + +Apache 2.0 - See [LICENSE](../../LICENSE) + +--- + +**VibeCheck**: Because what you watch should match how you feel, not what an algorithm wants you to feel. +**https://gist.github.com/michaeloboyle/b768dd2a80b2dd521d4552d2d8f1e8a1** \ No newline at end of file diff --git a/apps/vibecheck-ios/VerifyML.swift b/apps/vibecheck-ios/VerifyML.swift new file mode 100644 index 00000000..d179c4ef --- /dev/null +++ b/apps/vibecheck-ios/VerifyML.swift @@ -0,0 +1,32 @@ +import NaturalLanguage + +if #available(macOS 10.15, iOS 13.0, *) { + guard let embedding = NLEmbedding.sentenceEmbedding(for: .english) else { + print("Error: NLEmbedding model not found.") + exit(1) + } + + let text = "Comforting vibes" + if let vector = embedding.vector(for: text) { + print("SUCCESS: Generated vector of size \(vector.count) for '\(text)'") + + let v2 = embedding.vector(for: "Relaxing mood")! + + // Manual Cosine Sim + var dot = 0.0 + var mag1 = 0.0 + var mag2 = 0.0 + for i in 0.. 0.5)") + + } else { + print("Error: Failed to generate vector.") + } +} else { + print("Error: OS too old.") +} diff --git a/apps/vibecheck-ios/VibeCheck.xcodeproj/project.pbxproj b/apps/vibecheck-ios/VibeCheck.xcodeproj/project.pbxproj new file mode 100644 index 00000000..00275dcd --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck.xcodeproj/project.pbxproj @@ -0,0 +1,550 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 00000001 /* VibeCheckApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000001 /* VibeCheckApp.swift */; }; + 00000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000002 /* ContentView.swift */; }; + 00000003 /* MoodState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000003 /* MoodState.swift */; }; + 00000004 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000004 /* MediaItem.swift */; }; + 00000005 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000005 /* HealthKitManager.swift */; }; + 00000006 /* LocalStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000006 /* LocalStore.swift */; }; + 00000007 /* MoodClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000007 /* MoodClassifier.swift */; }; + 00000008 /* RecommendationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000008 /* RecommendationEngine.swift */; }; + 00000009 /* ForYouView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000009 /* ForYouView.swift */; }; + 0000000A /* VibeCheckView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1000000A /* VibeCheckView.swift */; }; + 0000000B /* WatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1000000B /* WatchlistView.swift */; }; + 0000000C /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1000000C /* SettingsView.swift */; }; + 0000000D /* MoodMeshBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1000000D /* MoodMeshBackground.swift */; }; + 0000000E /* VibeRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1000000E /* VibeRing.swift */; }; + 0000000F /* RecommendationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1000000F /* RecommendationCard.swift */; }; + 00000010 /* QuickMoodOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000010 /* QuickMoodOverride.swift */; }; + 00000011 /* VibeHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000011 /* VibeHeader.swift */; }; + 00000012 /* VibeWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000012 /* VibeWidget.swift */; }; + 00000014 /* VibePredictor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000014 /* VibePredictor.swift */; }; + 00000015 /* VectorEmbeddingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000015 /* VectorEmbeddingService.swift */; }; + 00000018 /* CloudKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000018 /* CloudKitManager.swift */; }; + 00000016 /* SommelierAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000016 /* SommelierAgent.swift */; }; + 00000017 /* ARWService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000017 /* ARWService.swift */; }; + 00000019 /* BenchmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10000019 /* BenchmarkView.swift */; }; + 00000020 /* HealthKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10000020 /* HealthKit.framework */; }; + 0000001A /* RuvectorBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1000001A /* RuvectorBridge.swift */; }; + 0000001B /* ruvector.wasm in Resources */ = {isa = PBXBuildFile; fileRef = 1000001B /* ruvector.wasm */; }; + 0000001C /* WasmKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7000001C /* WasmKit */; }; + 0000001D /* WasmKitWASI in Frameworks */ = {isa = PBXBuildFile; productRef = 7000001D /* WasmKitWASI */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 10000001 /* VibeCheckApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibeCheckApp.swift; sourceTree = ""; }; + 10000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 10000003 /* MoodState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodState.swift; sourceTree = ""; }; + 10000004 /* MediaItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaItem.swift; sourceTree = ""; }; + 10000005 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = ""; }; + 10000006 /* LocalStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalStore.swift; sourceTree = ""; }; + 10000007 /* MoodClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodClassifier.swift; sourceTree = ""; }; + 10000008 /* RecommendationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendationEngine.swift; sourceTree = ""; }; + 10000009 /* ForYouView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForYouView.swift; sourceTree = ""; }; + 1000000A /* VibeCheckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibeCheckView.swift; sourceTree = ""; }; + 1000000B /* WatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistView.swift; sourceTree = ""; }; + 1000000C /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 1000000D /* MoodMeshBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodMeshBackground.swift; sourceTree = ""; }; + 1000000E /* VibeRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibeRing.swift; sourceTree = ""; }; + 1000000F /* RecommendationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendationCard.swift; sourceTree = ""; }; + 10000010 /* QuickMoodOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickMoodOverride.swift; sourceTree = ""; }; + 10000011 /* VibeHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibeHeader.swift; sourceTree = ""; }; + 10000012 /* VibeWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibeWidget.swift; sourceTree = ""; }; + 10000014 /* VibePredictor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibePredictor.swift; sourceTree = ""; }; + 10000015 /* VectorEmbeddingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorEmbeddingService.swift; sourceTree = ""; }; + 10000018 /* CloudKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitManager.swift; sourceTree = ""; }; + 10000016 /* SommelierAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SommelierAgent.swift; sourceTree = ""; }; + 10000017 /* ARWService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ARWService.swift; sourceTree = ""; }; + 10000019 /* BenchmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BenchmarkView.swift; sourceTree = ""; }; + 10000013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 10000020 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = System/Library/Frameworks/HealthKit.framework; sourceTree = SDKROOT; }; + 10000100 /* VibeCheck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VibeCheck.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1000001A /* RuvectorBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuvectorBridge.swift; sourceTree = ""; }; + 1000001B /* ruvector.wasm */ = {isa = PBXFileReference; lastKnownFileType = file; path = ruvector.wasm; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 20000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 00000020 /* HealthKit.framework in Frameworks */, + 0000001C /* WasmKit in Frameworks */, + 0000001D /* WasmKitWASI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 30000001 = { + isa = PBXGroup; + children = ( + 30000002 /* VibeCheck */, + 30000010 /* Frameworks */, + 30000011 /* Products */, + ); + sourceTree = ""; + }; + 30000002 /* VibeCheck */ = { + isa = PBXGroup; + children = ( + 30000003 /* App */, + 30000004 /* Models */, + 30000005 /* Data */, + 30000006 /* Engine */, + 30000007 /* Views */, + 30000008 /* Widget */, + 30000009 /* Resources */, + ); + path = VibeCheck; + sourceTree = ""; + }; + 30000003 /* App */ = { + isa = PBXGroup; + children = ( + 10000001 /* VibeCheckApp.swift */, + 10000002 /* ContentView.swift */, + ); + path = App; + sourceTree = ""; + }; + 30000004 /* Models */ = { + isa = PBXGroup; + children = ( + 10000003 /* MoodState.swift */, + 10000004 /* MediaItem.swift */, + ); + path = Models; + sourceTree = ""; + }; + 30000005 /* Data */ = { + isa = PBXGroup; + children = ( + 10000005 /* HealthKitManager.swift */, + 10000006 /* LocalStore.swift */, + 10000018 /* CloudKitManager.swift */, + ); + path = Data; + sourceTree = ""; + }; + 30000006 /* Engine */ = { + isa = PBXGroup; + children = ( + 10000007 /* MoodClassifier.swift */, + 10000008 /* RecommendationEngine.swift */, + 10000014 /* VibePredictor.swift */, + 10000015 /* VectorEmbeddingService.swift */, + 10000016 /* SommelierAgent.swift */, + 10000017 /* ARWService.swift */, + 1000001A /* RuvectorBridge.swift */, + ); + path = Engine; + sourceTree = ""; + }; + 30000007 /* Views */ = { + isa = PBXGroup; + children = ( + 10000009 /* ForYouView.swift */, + 1000000A /* VibeCheckView.swift */, + 1000000B /* WatchlistView.swift */, + 1000000C /* SettingsView.swift */, + 10000019 /* BenchmarkView.swift */, + 30000012 /* Components */, + ); + path = Views; + sourceTree = ""; + }; + 30000008 /* Widget */ = { + isa = PBXGroup; + children = ( + 10000012 /* VibeWidget.swift */, + ); + path = Widget; + sourceTree = ""; + }; + 30000009 /* Resources */ = { + isa = PBXGroup; + children = ( + 10000013 /* Info.plist */, + 1000001B /* ruvector.wasm */, + ); + path = Resources; + sourceTree = ""; + }; + 30000010 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 10000020 /* HealthKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 30000011 /* Products */ = { + isa = PBXGroup; + children = ( + 10000100 /* VibeCheck.app */, + ); + name = Products; + sourceTree = ""; + }; + 30000012 /* Components */ = { + isa = PBXGroup; + children = ( + 1000000D /* MoodMeshBackground.swift */, + 1000000E /* VibeRing.swift */, + 1000000F /* RecommendationCard.swift */, + 10000010 /* QuickMoodOverride.swift */, + 10000011 /* VibeHeader.swift */, + ); + path = Components; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 40000001 /* VibeCheck */ = { + isa = PBXNativeTarget; + buildConfigurationList = 50000003 /* Build configuration list for PBXNativeTarget "VibeCheck" */; + buildPhases = ( + 40000002 /* Sources */, + 20000001 /* Frameworks */, + 40000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = VibeCheck; + packageProductDependencies = ( + 7000001C /* WasmKit */, + 7000001D /* WasmKitWASI */, + ); + productName = VibeCheck; + productReference = 10000100 /* VibeCheck.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 60000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1640; + TargetAttributes = { + 40000001 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = 50000001 /* Build configuration list for PBXProject "VibeCheck" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 30000001; + packageReferences = ( + 8000001C /* XCRemoteSwiftPackageReference "WasmKit" */, + ); + productRefGroup = 30000011 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 40000001 /* VibeCheck */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 40000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0000001B /* ruvector.wasm in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 40000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 00000001 /* VibeCheckApp.swift in Sources */, + 00000002 /* ContentView.swift in Sources */, + 00000003 /* MoodState.swift in Sources */, + 00000004 /* MediaItem.swift in Sources */, + 00000005 /* HealthKitManager.swift in Sources */, + 00000006 /* LocalStore.swift in Sources */, + 00000007 /* MoodClassifier.swift in Sources */, + 00000008 /* RecommendationEngine.swift in Sources */, + 00000009 /* ForYouView.swift in Sources */, + 0000000A /* VibeCheckView.swift in Sources */, + 0000000B /* WatchlistView.swift in Sources */, + 0000000C /* SettingsView.swift in Sources */, + 0000000D /* MoodMeshBackground.swift in Sources */, + 0000000E /* VibeRing.swift in Sources */, + 0000000F /* RecommendationCard.swift in Sources */, + 00000010 /* QuickMoodOverride.swift in Sources */, + 00000011 /* VibeHeader.swift in Sources */, + 00000012 /* VibeWidget.swift in Sources */, + 00000014 /* VibePredictor.swift in Sources */, + 00000015 /* VectorEmbeddingService.swift in Sources */, + 00000018 /* CloudKitManager.swift in Sources */, + 00000016 /* SommelierAgent.swift in Sources */, + 00000017 /* ARWService.swift in Sources */, + 00000019 /* BenchmarkView.swift in Sources */, + 0000001A /* RuvectorBridge.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 50000002 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = YTT8AJUSP5; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 50000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = YTT8AJUSP5; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 50000005 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = VibeCheck/Resources/VibeCheck.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VibeCheck/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VibeCheck; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_NSHealthShareUsageDescription = "VibeCheck reads your health data locally to understand your current energy and stress levels. This data never leaves your device."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.agentics.vibecheck; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 50000006 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = VibeCheck/Resources/VibeCheck.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VibeCheck/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VibeCheck; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; + INFOPLIST_KEY_NSHealthShareUsageDescription = "VibeCheck reads your health data locally to understand your current energy and stress levels. This data never leaves your device."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.agentics.vibecheck; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 50000001 /* Build configuration list for PBXProject "VibeCheck" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50000002 /* Debug */, + 50000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 50000003 /* Build configuration list for PBXNativeTarget "VibeCheck" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 50000005 /* Debug */, + 50000006 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 8000001C /* XCRemoteSwiftPackageReference "WasmKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/swiftwasm/WasmKit"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7000001C /* WasmKit */ = { + isa = XCSwiftPackageProductDependency; + package = 8000001C /* XCRemoteSwiftPackageReference "WasmKit" */; + productName = WasmKit; + }; + 7000001D /* WasmKitWASI */ = { + isa = XCSwiftPackageProductDependency; + package = 8000001C /* XCRemoteSwiftPackageReference "WasmKit" */; + productName = WasmKitWASI; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 60000001 /* Project object */; +} diff --git a/apps/vibecheck-ios/VibeCheck.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/vibecheck-ios/VibeCheck.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/vibecheck-ios/VibeCheck/App/ContentView.swift b/apps/vibecheck-ios/VibeCheck/App/ContentView.swift new file mode 100644 index 00000000..4caff10f --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/App/ContentView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct ContentView: View { + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + ForYouView() + .tabItem { + Label("For You", systemImage: "sparkles.rectangle.stack") + } + .tag(0) + + VibeCheckView() + .tabItem { + Label("Vibe Check", systemImage: "waveform.path.ecg") + } + .tag(1) + + WatchlistView() + .tabItem { + Label("Watchlist", systemImage: "bookmark.fill") + } + .tag(2) + + SettingsView() + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(3) + } + .tint(.primary) + } +} + +#Preview { + ContentView() +} diff --git a/apps/vibecheck-ios/VibeCheck/App/VibeCheckApp.swift b/apps/vibecheck-ios/VibeCheck/App/VibeCheckApp.swift new file mode 100644 index 00000000..86a8de71 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/App/VibeCheckApp.swift @@ -0,0 +1,93 @@ +import SwiftUI +import SwiftData + +@main +struct VibeCheckApp: App { + var sharedModelContainer: ModelContainer = { + let schema = Schema([ + WatchHistory.self, + UserPreferences.self, + MoodLog.self, + WatchlistItem.self, + MediaInteraction.self + ]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(sharedModelContainer) + } +} + +// MARK: - App Theme (Moved here for compilation) +struct AppTheme { + // MARK: - Icon Palette + // Derived from the "Futuristic Minimal" app icon + static let iconPurple = Color(red: 0.5, green: 0.0, blue: 1.0) // Deep Electric Purple + static let iconBlue = Color(red: 0.0, green: 0.4, blue: 1.0) // Vivid Blue + static let iconDarkBg = Color(red: 0.05, green: 0.05, blue: 0.1) // Almost Black/Dark Navy + + static let accentGradient = LinearGradient( + colors: [iconPurple, iconBlue], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // MARK: - Mood Palettes + // Overrides for specific moods to align with the "Icon Theme" + static func colors(for mood: String) -> [Color] { + switch mood { + case "comfort": + return [ + iconPurple.opacity(0.6), .pink.opacity(0.4), iconBlue.opacity(0.5), + .pink.opacity(0.4), iconPurple.opacity(0.5), .pink.opacity(0.3), + iconBlue.opacity(0.4), iconPurple.opacity(0.6), .pink.opacity(0.4) + ] + case "gentle": + return [ + iconBlue.opacity(0.4), iconPurple.opacity(0.3), .cyan.opacity(0.3), + iconPurple.opacity(0.3), iconBlue.opacity(0.3), .indigo.opacity(0.2), + .cyan.opacity(0.2), iconBlue.opacity(0.4), iconPurple.opacity(0.3) + ] + case "light": + return [ + .yellow.opacity(0.5), .orange.opacity(0.4), iconPurple.opacity(0.2), + .orange.opacity(0.3), .yellow.opacity(0.4), .orange.opacity(0.2), + iconPurple.opacity(0.2), .orange.opacity(0.3), .yellow.opacity(0.5) + ] + case "engaging": + return [ + .teal.opacity(0.5), .green.opacity(0.4), iconBlue.opacity(0.4), + .green.opacity(0.3), .teal.opacity(0.4), .mint.opacity(0.3), + iconBlue.opacity(0.3), .teal.opacity(0.4), .green.opacity(0.5) + ] + case "exciting": + return [ + .red.opacity(0.5), iconPurple.opacity(0.5), iconBlue.opacity(0.4), + iconPurple.opacity(0.5), .red.opacity(0.4), iconPurple.opacity(0.4), + iconBlue.opacity(0.3), iconPurple.opacity(0.5), .red.opacity(0.5) + ] + case "calming": + return [ + iconBlue.opacity(0.5), .teal.opacity(0.3), iconDarkBg.opacity(0.8), + .teal.opacity(0.3), iconBlue.opacity(0.4), .cyan.opacity(0.2), + iconDarkBg.opacity(0.6), .teal.opacity(0.3), iconBlue.opacity(0.5) + ] + default: // balanced + return [ + iconDarkBg.opacity(0.5), iconBlue.opacity(0.2), iconPurple.opacity(0.2), + iconBlue.opacity(0.2), iconDarkBg.opacity(0.4), .teal.opacity(0.1), + iconPurple.opacity(0.2), iconBlue.opacity(0.2), iconDarkBg.opacity(0.5) + ] + } + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Data/CloudKitManager.swift b/apps/vibecheck-ios/VibeCheck/Data/CloudKitManager.swift new file mode 100644 index 00000000..7ce9860a --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Data/CloudKitManager.swift @@ -0,0 +1,123 @@ +import Foundation +import CloudKit + +import Foundation +import CloudKit + +// MARK: - Protocols for Dependency Injection + +protocol CKDatabaseProtocol { + func save(_ record: CKRecord, completionHandler: @escaping (CKRecord?, Error?) -> Void) + func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZone.ID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void) +} + +protocol CKContainerProtocol { + var privateCloudDatabaseProtocol: CKDatabaseProtocol { get } +} + +// MARK: - Data Model + +struct FamilyMemberVibe: Identifiable { + let id: UUID + let userID: String + let vibeKeyword: String + let vibeEnergy: String + let vibeStress: String + let colorHex: String + let lastUpdated: Date + + // Convert to CKRecord + func toCKRecord() -> CKRecord { + let recordID = CKRecord.ID(recordName: userID) // One record per user + let record = CKRecord(recordType: "FamilyVibe", recordID: recordID) + record["user_id"] = userID + record["vibe_keyword"] = vibeKeyword + record["vibe_energy"] = vibeEnergy + record["vibe_stress"] = vibeStress + record["color_hex"] = colorHex + record["last_updated"] = lastUpdated + return record + } +} + +// MARK: - CloudKit Manager + +class CloudKitManager { + static let shared = CloudKitManager() + + // Dependency Injection for Testing + private let container: CKContainerProtocol + private let database: CKDatabaseProtocol + + init(container: CKContainerProtocol = CKContainer.default()) { + self.container = container + self.database = container.privateCloudDatabaseProtocol // Using Private DB for Hackathon simplicity + } + + func publishVibe(_ vibe: FamilyMemberVibe, completion: @escaping (Result) -> Void) { + let record = vibe.toCKRecord() + + database.save(record) { savedRecord, error in + if let error = error { + completion(.failure(error)) + return + } + completion(.success(())) + } + } + + // Fetch function needed for future tests/features + func fetchFamilyVibes(completion: @escaping (Result<[FamilyMemberVibe], Error>) -> Void) { + let predicate = NSPredicate(value: true) // Fetch all family vibes + let query = CKQuery(recordType: "FamilyVibe", predicate: predicate) + query.sortDescriptors = [NSSortDescriptor(key: "last_updated", ascending: false)] + + database.perform(query, inZoneWith: nil) { records, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let records = records else { + completion(.success([])) + return + } + + let vibes = records.compactMap { record -> FamilyMemberVibe? in + guard let userID = record["user_id"] as? String, + let vibeKeyword = record["vibe_keyword"] as? String, + let vibeEnergy = record["vibe_energy"] as? String, + let vibeStress = record["vibe_stress"] as? String, + let colorHex = record["color_hex"] as? String, + let lastUpdated = record["last_updated"] as? Date else { + return nil + } + + return FamilyMemberVibe( + id: UUID(), // Using UUID for local Identifiable compliance + userID: userID, + vibeKeyword: vibeKeyword, + vibeEnergy: vibeEnergy, + vibeStress: vibeStress, + colorHex: colorHex, + lastUpdated: lastUpdated + ) + } + + completion(.success(vibes)) + } + } +} + +// MARK: - Extensions for Real CloudKit mapping + +extension CKContainer: CKContainerProtocol { + var privateCloudDatabaseProtocol: CKDatabaseProtocol { + return self.privateCloudDatabase + } +} + +extension CKDatabase: CKDatabaseProtocol { + // Protocol requirements match existing methods exactly + // Explicit conformance is automatic +} diff --git a/apps/vibecheck-ios/VibeCheck/Data/HealthKitManager.swift b/apps/vibecheck-ios/VibeCheck/Data/HealthKitManager.swift new file mode 100644 index 00000000..df24dd65 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Data/HealthKitManager.swift @@ -0,0 +1,199 @@ +import Foundation +import HealthKit +import Observation + +@Observable +class HealthKitManager { + private let healthStore = HKHealthStore() + + var currentHRV: Double? + var lastSleepHours: Double? + var restingHeartRate: Double? + var stepsToday: Double? + var activityLevel: ActivityLevel = .unknown + var isLoading = false + var isAuthorized = false + var errorMessage: String? + + enum ActivityLevel: String, CaseIterable { + case sedentary = "Sedentary" + case light = "Light" + case moderate = "Moderate" + case active = "Active" + case unknown = "Unknown" + } + + private let readTypes: Set = { + var types = Set() + if let hrvType = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN) { + types.insert(hrvType) + } + if let restingHRType = HKObjectType.quantityType(forIdentifier: .restingHeartRate) { + types.insert(restingHRType) + } + if let stepsType = HKObjectType.quantityType(forIdentifier: .stepCount) { + types.insert(stepsType) + } + if let activeEnergyType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned) { + types.insert(activeEnergyType) + } + if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { + types.insert(sleepType) + } + return types + }() + + var isHealthDataAvailable: Bool { + HKHealthStore.isHealthDataAvailable() + } + + func requestAuthorization() async throws { + guard isHealthDataAvailable else { + errorMessage = "Health data not available on this device" + return + } + + try await healthStore.requestAuthorization(toShare: [], read: readTypes) + isAuthorized = true + } + + func fetchCurrentContext() async { + isLoading = true + defer { isLoading = false } + + await withTaskGroup(of: Void.self) { group in + group.addTask { await self.fetchHRV() } + group.addTask { await self.fetchSleep() } + group.addTask { await self.fetchSteps() } + group.addTask { await self.fetchRestingHeartRate() } + } + } + + // MARK: - HRV + private func fetchHRV() async { + guard let type = HKQuantityType.quantityType(forIdentifier: .heartRateVariabilitySDNN) else { return } + + let predicate = HKQuery.predicateForSamples( + withStart: Date().addingTimeInterval(-3600 * 6), // last 6 hours + end: Date() + ) + + do { + let descriptor = HKSampleQueryDescriptor( + predicates: [.quantitySample(type: type, predicate: predicate)], + sortDescriptors: [SortDescriptor(\.startDate, order: .reverse)], + limit: 1 + ) + + let results = try await descriptor.result(for: healthStore) + if let sample = results.first { + currentHRV = sample.quantity.doubleValue(for: HKUnit.secondUnit(with: .milli)) + } + } catch { + print("HRV fetch error: \(error)") + } + } + + // MARK: - Sleep + private func fetchSleep() async { + guard let type = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return } + + let calendar = Calendar.current + let startOfYesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date()))! + + let predicate = HKQuery.predicateForSamples( + withStart: startOfYesterday, + end: Date() + ) + + do { + let descriptor = HKSampleQueryDescriptor( + predicates: [.categorySample(type: type, predicate: predicate)], + sortDescriptors: [SortDescriptor(\.startDate, order: .reverse)] + ) + + let results = try await descriptor.result(for: healthStore) + + // Filter for actual asleep states (not in-bed) + let asleepValues: Set = [ + HKCategoryValueSleepAnalysis.asleepCore.rawValue, + HKCategoryValueSleepAnalysis.asleepDeep.rawValue, + HKCategoryValueSleepAnalysis.asleepREM.rawValue, + HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue + ] + + let asleepSamples = results.filter { asleepValues.contains($0.value) } + + let totalSleepSeconds = asleepSamples.reduce(0.0) { total, sample in + total + sample.endDate.timeIntervalSince(sample.startDate) + } + + lastSleepHours = totalSleepSeconds / 3600.0 + } catch { + print("Sleep fetch error: \(error)") + } + } + + // MARK: - Steps + private func fetchSteps() async { + guard let type = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return } + + let startOfDay = Calendar.current.startOfDay(for: Date()) + let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: Date()) + + do { + let descriptor = HKStatisticsQueryDescriptor( + predicate: .quantitySample(type: type, predicate: predicate), + options: .cumulativeSum + ) + + let result = try await descriptor.result(for: healthStore) + if let sum = result?.sumQuantity() { + let steps = sum.doubleValue(for: .count()) + stepsToday = steps + activityLevel = classifyActivity(steps: steps) + } + } catch { + print("Steps fetch error: \(error)") + } + } + + // MARK: - Resting Heart Rate + private func fetchRestingHeartRate() async { + guard let type = HKQuantityType.quantityType(forIdentifier: .restingHeartRate) else { return } + + let predicate = HKQuery.predicateForSamples( + withStart: Date().addingTimeInterval(-86400), // last 24 hours + end: Date() + ) + + do { + let descriptor = HKSampleQueryDescriptor( + predicates: [.quantitySample(type: type, predicate: predicate)], + sortDescriptors: [SortDescriptor(\.startDate, order: .reverse)], + limit: 1 + ) + + let results = try await descriptor.result(for: healthStore) + if let sample = results.first { + restingHeartRate = sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) + } + } catch { + print("Resting HR fetch error: \(error)") + } + } + + // MARK: - Helpers + func classifyActivity(steps: Double) -> ActivityLevel { + switch steps { + case 0..<2000: + return .sedentary + case 2000..<5000: + return .light + case 5000..<10000: + return .moderate + default: + return .active + } + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Data/LocalStore.swift b/apps/vibecheck-ios/VibeCheck/Data/LocalStore.swift new file mode 100644 index 00000000..578b846f --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Data/LocalStore.swift @@ -0,0 +1,100 @@ +import Foundation +import SwiftData + +@Model +class WatchHistory { + var mediaId: String + var mediaTitle: String + var timestamp: Date + var completionPercent: Double + var moodHint: String? + + init( + mediaId: String, + mediaTitle: String, + timestamp: Date = Date(), + completionPercent: Double = 0, + moodHint: String? = nil + ) { + self.mediaId = mediaId + self.mediaTitle = mediaTitle + self.timestamp = timestamp + self.completionPercent = completionPercent + self.moodHint = moodHint + } +} + +@Model +class UserPreferences { + var favoriteGenres: [String] + var avoidGenres: [String] + var avoidTitles: [String] + var preferredMinRuntime: Int? + var preferredMaxRuntime: Int? + var subscriptions: [String] + + init( + favoriteGenres: [String] = [], + avoidGenres: [String] = [], + avoidTitles: [String] = [], + preferredMinRuntime: Int? = nil, + preferredMaxRuntime: Int? = nil, + subscriptions: [String] = [] + ) { + self.favoriteGenres = favoriteGenres + self.avoidGenres = avoidGenres + self.avoidTitles = avoidTitles + self.preferredMinRuntime = preferredMinRuntime + self.preferredMaxRuntime = preferredMaxRuntime + self.subscriptions = subscriptions + } + + static var `default`: UserPreferences { + UserPreferences( + favoriteGenres: ["comedy", "drama", "sci-fi"], + avoidGenres: [], + avoidTitles: [], + subscriptions: ["netflix", "hulu", "apple", "max", "prime"] + ) + } +} + +@Model +class MoodLog { + var timestamp: Date + var energy: String + var stress: String + var hrv: Double? + var sleepHours: Double? + var steps: Double? + var recommendationHint: String + + init(mood: MoodState, hrv: Double? = nil, sleepHours: Double? = nil, steps: Double? = nil) { + self.timestamp = Date() + self.energy = mood.energy.rawValue + self.stress = mood.stress.rawValue + self.hrv = hrv + self.sleepHours = sleepHours + self.steps = steps + self.recommendationHint = mood.recommendationHint + } +} + +@Model +class WatchlistItem { + var mediaId: String + var mediaTitle: String + var addedDate: Date + var platform: String? + var notes: String? + + init(mediaId: String, mediaTitle: String, platform: String? = nil, notes: String? = nil) { + self.mediaId = mediaId + self.mediaTitle = mediaTitle + self.addedDate = Date() + self.platform = platform + self.notes = notes + } +} + + diff --git a/apps/vibecheck-ios/VibeCheck/Data/MediaInteraction.swift b/apps/vibecheck-ios/VibeCheck/Data/MediaInteraction.swift new file mode 100644 index 00000000..b9af5aae --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Data/MediaInteraction.swift @@ -0,0 +1,273 @@ +// +// MediaInteraction.swift +// VibeCheck +// +// SwiftData model for tracking user interactions with media content. +// Supports thumbs up/down ratings and "seen" status tracking. +// Integrates with LearningMemoryService for WASM-based personalization. +// + +import Foundation +import SwiftData + +// MARK: - Rating Type + +/// User rating for media content +enum Rating: String, Codable, CaseIterable { + case thumbsUp = "thumbsUp" + case thumbsDown = "thumbsDown" + + /// Map to existing FeedbackType for learning system integration + var feedbackType: FeedbackType { + switch self { + case .thumbsUp: return .liked + case .thumbsDown: return .disliked + } + } + + /// SF Symbol icon name + var iconName: String { + switch self { + case .thumbsUp: return "hand.thumbsup.fill" + case .thumbsDown: return "hand.thumbsdown.fill" + } + } + + /// Unselected SF Symbol icon name + var iconNameOutline: String { + switch self { + case .thumbsUp: return "hand.thumbsup" + case .thumbsDown: return "hand.thumbsdown" + } + } + + /// Learning score (-1.0 to 1.0) + var learningScore: Float { + return feedbackType.learningScore + } + + /// Accessibility label + var accessibilityLabel: String { + switch self { + case .thumbsUp: return "Like" + case .thumbsDown: return "Dislike" + } + } +} + +// MARK: - MediaInteraction Model + +/// Tracks user interactions with media content for personalization. +/// Stores ratings (thumbs up/down) and seen status. +@available(iOS 17.0, *) +@Model +class MediaInteraction { + // MARK: - Properties + + /// Unique identifier for the media item + @Attribute(.unique) var mediaId: String + + /// Title of the media (for display without re-fetching) + var mediaTitle: String + + /// User's rating (nil = no rating) + var ratingRawValue: String? + + /// Whether the user has seen/watched this content + var hasSeen: Bool + + /// Timestamp when marked as seen (nil if not seen) + var seenAt: Date? + + /// Mood context when interaction occurred + var moodHint: String? + + /// Timestamp when the interaction was created + var createdAt: Date + + /// Timestamp of the last update + var updatedAt: Date + + // MARK: - Computed Properties + + /// Typed rating accessor + var rating: Rating? { + get { + guard let rawValue = ratingRawValue else { return nil } + return Rating(rawValue: rawValue) + } + set { + ratingRawValue = newValue?.rawValue + updatedAt = Date() + } + } + + /// Learning score for recommendation engine + /// - thumbsUp: 1.0 + /// - thumbsDown: -1.0 + /// - seen (no rating): 0.5 + /// - no interaction: 0.0 + var learningScore: Float { + if let rating = rating { + return rating.learningScore + } else if hasSeen { + return FeedbackType.watched.learningScore + } + return 0.0 + } + + /// Feedback type for LearningMemoryService + var feedbackType: FeedbackType? { + if let rating = rating { + return rating.feedbackType + } else if hasSeen { + return .watched + } + return nil + } + + // MARK: - Initialization + + init( + mediaId: String, + mediaTitle: String, + rating: Rating? = nil, + hasSeen: Bool = false, + moodHint: String? = nil + ) { + self.mediaId = mediaId + self.mediaTitle = mediaTitle + self.ratingRawValue = rating?.rawValue + self.hasSeen = hasSeen + self.moodHint = moodHint + self.createdAt = Date() + self.updatedAt = Date() + self.seenAt = hasSeen ? Date() : nil + } + + // MARK: - Actions + + /// Toggle rating - if same rating, clear it; otherwise set new rating + func toggleRating(_ newRating: Rating) { + if rating == newRating { + rating = nil + } else { + rating = newRating + } + } + + /// Mark content as seen + func markAsSeen() { + hasSeen = true + seenAt = Date() + updatedAt = Date() + } + + /// Mark content as unseen + func markAsUnseen() { + hasSeen = false + seenAt = nil + updatedAt = Date() + } + + /// Toggle seen status + func toggleSeen() { + if hasSeen { + markAsUnseen() + } else { + markAsSeen() + } + } + + // MARK: - Static Helpers + + /// Find existing interaction or create new one + @MainActor + static func findOrCreate( + mediaId: String, + mediaTitle: String, + in context: ModelContext + ) throws -> MediaInteraction { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == mediaId } + ) + + if let existing = try context.fetch(descriptor).first { + return existing + } + + let new = MediaInteraction(mediaId: mediaId, mediaTitle: mediaTitle) + context.insert(new) + return new + } + + /// Fetch interaction by media ID + @MainActor + static func find( + mediaId: String, + in context: ModelContext + ) throws -> MediaInteraction? { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == mediaId } + ) + return try context.fetch(descriptor).first + } + + /// Fetch all interactions with a specific rating + @MainActor + static func fetchByRating( + _ rating: Rating, + in context: ModelContext + ) throws -> [MediaInteraction] { + let ratingRaw = rating.rawValue + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.ratingRawValue == ratingRaw }, + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + ) + return try context.fetch(descriptor) + } + + /// Fetch all seen interactions + @MainActor + static func fetchSeen( + in context: ModelContext + ) throws -> [MediaInteraction] { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.hasSeen == true }, + sortBy: [SortDescriptor(\.seenAt, order: .reverse)] + ) + return try context.fetch(descriptor) + } + + /// Fetch interactions for multiple media IDs + @MainActor + static func fetchMultiple( + mediaIds: [String], + in context: ModelContext + ) throws -> [MediaInteraction] { + let descriptor = FetchDescriptor( + predicate: #Predicate { mediaIds.contains($0.mediaId) } + ) + return try context.fetch(descriptor) + } +} + +// MARK: - Extensions for MediaItem + +extension MediaItem { + /// Create a MediaInteraction for this item + @available(iOS 17.0, *) + func createInteraction( + rating: Rating? = nil, + hasSeen: Bool = false, + moodHint: String? = nil + ) -> MediaInteraction { + MediaInteraction( + mediaId: id, + mediaTitle: title, + rating: rating, + hasSeen: hasSeen, + moodHint: moodHint + ) + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Engine/ARWService.swift b/apps/vibecheck-ios/VibeCheck/Engine/ARWService.swift new file mode 100644 index 00000000..06b5fce4 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Engine/ARWService.swift @@ -0,0 +1,207 @@ +import Foundation + +// MARK: - ARW Models +struct ARWManifest: Codable { + let version: String + let profile: String + let site: ARWSite + let actions: [ARWAction] +} + +struct ARWSite: Codable { + let name: String + let description: String +} + +struct ARWAction: Codable { + let id: String + let endpoint: String + let method: String +} + +struct ARWSearchResponse: Codable { + let success: Bool + let results: [ARWSearchResult] +} + +struct ARWSearchResult: Codable { + let content: ARWMediaContent + let relevanceScore: Double + let matchReasons: [String] + let explanation: String? +} + +struct ARWMediaContent: Codable { + let id: Int + let title: String? + let name: String? + let overview: String + let posterPath: String? + let backdropPath: String? + let voteAverage: Double + let mediaType: String + let genreIds: [Int] + let releaseDate: String? + let firstAirDate: String? + + var displayTitle: String { + return title ?? name ?? "Unknown Title" + } + + var displayYear: Int { + let dateString = releaseDate ?? firstAirDate + if let yearStr = dateString?.prefix(4), let year = Int(yearStr) { + return year + } + return Calendar.current.component(.year, from: Date()) + } +} + +// MARK: - ARW Service +class ARWService { + static let shared = ARWService() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() + + // Allow injection for testing + private var session: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 20 // Increased for verification + return URLSession(configuration: config) + }() + + private var manifest: ARWManifest? + // Use LAN IP for physical device testing (replace with your machine's IP) + private let baseURLString = "http://localhost:3000" + + private init() {} + + // For testing + func configure(session: URLSession) { + self.session = session + } + + func fetchManifest() async throws -> ARWManifest { + if let cached = manifest { return cached } + + guard let url = URL(string: "\(baseURLString)/.well-known/arw-manifest.json") else { + throw URLError(.badURL) + } + + let (data, response) = try await session.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + + let manifest = try decoder.decode(ARWManifest.self, from: data) + self.manifest = manifest + return manifest + } + + func search(query: String) async throws -> [MediaItem] { + // Ensure manifest is loaded (or fetch it) + let manifest = try await fetchManifest() + + guard let action = manifest.actions.first(where: { $0.id == "semantic_search" }) else { + print("ARW: content action not found") + return [] + } + + let endpoint = action.endpoint.hasPrefix("http") ? action.endpoint : "\(baseURLString)\(action.endpoint)" + + guard let url = URL(string: endpoint) else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = action.method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = [ + "query": query, + "explain": true, + "limit": 10 + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + if let errorStr = String(data: data, encoding: .utf8) { + print("ARW Error: \(errorStr)") + } + throw URLError(.badServerResponse) + } + + let searchResponse = try decoder.decode(ARWSearchResponse.self, from: data) + + return searchResponse.results.compactMap { mapToMediaItem($0) } + } + + private func mapToMediaItem(_ result: ARWSearchResult) -> MediaItem { + let content = result.content + let genres = mapGenres(ids: content.genreIds, type: content.mediaType) + let tone = inferTone(from: result.matchReasons, genreIds: content.genreIds) + + var item = MediaItem( + id: String(content.id), + title: content.displayTitle, + overview: content.overview, + genres: genres, + tone: tone, + intensity: calculateIntensity(voteAverage: content.voteAverage, genreIds: content.genreIds), + runtime: 90, + year: content.displayYear, + platforms: ["arw"], + posterPath: content.posterPath, + backdropPath: content.backdropPath, + rating: content.voteAverage + ) + // Map ARW explanation to Sommelier rationale + item.sommelierRationale = result.explanation + return item + } + + private func mapGenres(ids: [Int], type: String) -> [String] { + var names: [String] = [] + let map: [Int: String] = [ + 28: "Action", 12: "Adventure", 16: "Animation", 35: "Comedy", + 80: "Crime", 99: "Documentary", 18: "Drama", 10751: "Family", + 14: "Fantasy", 36: "History", 27: "Horror", 10402: "Music", + 9648: "Mystery", 10749: "Romance", 878: "Sci-Fi", 10770: "TV Movie", + 53: "Thriller", 10752: "War", 37: "Western", + 10759: "Action & Adventure", 10765: "Sci-Fi & Fantasy", 10768: "War & Politics" + ] + + for id in ids { + if let name = map[id] { + names.append(name.lowercased()) + } + } + return names.isEmpty ? ["unknown"] : names + } + + private func inferTone(from reasons: [String], genreIds: [Int]) -> [String] { + var tones: [String] = [] + if genreIds.contains(35) { tones.append("light") } + if genreIds.contains(27) || genreIds.contains(53) { tones.append("intense") } + if genreIds.contains(18) { tones.append("emotional") } + if genreIds.contains(99) { tones.append("calm") } + if tones.isEmpty { tones.append("engaging") } + return tones + } + + private func calculateIntensity(voteAverage: Double, genreIds: [Int]) -> Double { + if genreIds.contains(28) || genreIds.contains(27) { return 0.8 } + if genreIds.contains(35) || genreIds.contains(10751) { return 0.3 } + return 0.5 + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Engine/MoodClassifier.swift b/apps/vibecheck-ios/VibeCheck/Engine/MoodClassifier.swift new file mode 100644 index 00000000..4dfdd773 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Engine/MoodClassifier.swift @@ -0,0 +1,168 @@ +import Foundation + +class MoodClassifier { + + /// Classify the user's current mood based on biometric data + func classify( + hrv: Double?, + sleepHours: Double?, + restingHR: Double?, + activity: HealthKitManager.ActivityLevel, + timeOfDay: Date = Date() + ) -> MoodState { + + let energy = classifyEnergy( + sleepHours: sleepHours, + activity: activity, + timeOfDay: timeOfDay + ) + + let stress = classifyStress( + hrv: hrv, + restingHR: restingHR + ) + + let confidence = calculateConfidence( + hasHRV: hrv != nil, + hasSleep: sleepHours != nil, + hasActivity: activity != .unknown + ) + + return MoodState(energy: energy, stress: stress, confidence: confidence) + } + + // MARK: - Energy Classification + + private func classifyEnergy( + sleepHours: Double?, + activity: HealthKitManager.ActivityLevel, + timeOfDay: Date + ) -> MoodState.Energy { + + let hour = Calendar.current.component(.hour, from: timeOfDay) + let isLateNight = hour >= 23 || hour < 5 + let isEarlyMorning = hour >= 5 && hour < 8 + + var score = 0.0 + + // Sleep contribution (-2 to +1) + if let sleep = sleepHours { + switch sleep { + case 0..<4: + score -= 2.0 + case 4..<5: + score -= 1.5 + case 5..<6: + score -= 0.5 + case 6..<7: + score += 0.0 + case 7..<8: + score += 0.5 + case 8..<9: + score += 1.0 + default: + // Oversleep can mean fatigue + score += 0.5 + } + } + + // Activity contribution (-0.5 to +1) + switch activity { + case .sedentary: + score -= 0.5 + case .light: + score += 0.0 + case .moderate: + score += 0.5 + case .active: + score += 1.0 + case .unknown: + break + } + + // Time of day modifiers + if isLateNight { + score -= 1.0 + } else if isEarlyMorning { + score -= 0.3 + } + + // Map score to energy level + switch score { + case ..<(-1.5): + return .exhausted + case -1.5..<(-0.5): + return .low + case -0.5..<0.5: + return .moderate + case 0.5..<1.5: + return .high + default: + return .wired + } + } + + // MARK: - Stress Classification + + private func classifyStress(hrv: Double?, restingHR: Double?) -> MoodState.Stress { + // HRV: Higher values generally indicate better recovery and lower stress + // Typical ranges vary widely by individual (20-80ms common) + // This is a simplified model - ideally would be personalized to user's baseline + + guard let hrv = hrv else { + // If no HRV, try to use resting HR as a secondary indicator + if let rhr = restingHR { + // Lower resting HR generally = better cardiovascular health / less acute stress + switch rhr { + case 0..<55: + return .relaxed + case 55..<65: + return .calm + case 65..<75: + return .neutral + case 75..<85: + return .tense + default: + return .stressed + } + } + return .neutral + } + + // HRV-based classification + switch hrv { + case 70...: + return .relaxed + case 50..<70: + return .calm + case 35..<50: + return .neutral + case 20..<35: + return .tense + default: + return .stressed + } + } + + // MARK: - Confidence + + private func calculateConfidence( + hasHRV: Bool, + hasSleep: Bool, + hasActivity: Bool + ) -> Double { + var score = 0.3 // Base confidence + + if hasHRV { + score += 0.3 + } + if hasSleep { + score += 0.2 + } + if hasActivity { + score += 0.2 + } + + return min(score, 1.0) + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Engine/RecommendationEngine+Ruvector.swift b/apps/vibecheck-ios/VibeCheck/Engine/RecommendationEngine+Ruvector.swift new file mode 100644 index 00000000..8c1547fc --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Engine/RecommendationEngine+Ruvector.swift @@ -0,0 +1,188 @@ +// +// RecommendationEngine+Ruvector.swift +// VibeCheck +// +// Ruvector WASM integration for RecommendationEngine +// Add this file after completing Xcode integration steps +// + +import Foundation + +extension RecommendationEngine { + + // MARK: - Ruvector Integration + + /// Initialize Ruvector WASM module + /// Call this in init() or on first use + func initializeRuvector() async { + guard let wasmPath = Bundle.main.path(forResource: "ruvector", ofType: "wasm") else { + print("⚠️ ruvector.wasm not found in bundle") + return + } + + do { + try await ruvectorBridge.load(wasmPath: wasmPath) + print("✅ Ruvector WASM loaded successfully") + } catch { + print("❌ Failed to load Ruvector: \(error)") + } + } + + /// Get hybrid recommendations (Ruvector + ARW + Local) + func getHybridRecommendations( + for context: VibeContext, + limit: Int = 20 + ) async -> [MediaItem] { + var results: [MediaItem] = [] + + // Strategy 1: Try Ruvector first (learned, personalized) + if ruvectorBridge.isReady { + do { + let localRecs = try await ruvectorBridge.getRecommendations( + for: context, + limit: limit / 2 // Reserve half for remote + ) + results.append(contentsOf: localRecs) + print("✅ Ruvector: \(localRecs.count) recommendations") + } catch { + print("⚠️ Ruvector failed: \(error)") + } + } + + // Strategy 2: ARW backend (remote, semantic) + do { + let remoteRecs = try await arwService.search( + query: context.keywords.joined(separator: " "), + limit: limit / 2 + ) + results.append(contentsOf: remoteRecs) + print("✅ ARW: \(remoteRecs.count) recommendations") + } catch { + print("⚠️ ARW failed: \(error)") + } + + // Strategy 3: Local fallback + if results.count < limit { + let localRecs = localStore.recommend( + for: context, + limit: limit - results.count + ) + results.append(contentsOf: localRecs) + print("✅ Local: \(localRecs.count) recommendations") + } + + // Deduplicate by ID + var seen = Set() + results = results.filter { item in + guard !seen.contains(item.id) else { return false } + seen.insert(item.id) + return true + } + + return Array(results.prefix(limit)) + } + + /// Record watch event for learning + func recordWatchEvent( + _ item: MediaItem, + context: VibeContext, + durationSeconds: Int + ) async { + guard ruvectorBridge.isReady else { return } + + do { + try await ruvectorBridge.recordWatchEvent( + item, + context: context, + durationSeconds: durationSeconds + ) + print("✅ Recorded watch event: \(item.title) (\(durationSeconds)s)") + } catch { + print("❌ Failed to record watch event: \(error)") + } + } + + /// Learn from user satisfaction + func learnFromSatisfaction(_ satisfaction: Double) async { + guard ruvectorBridge.isReady else { return } + + do { + try await ruvectorBridge.learn(satisfaction: satisfaction) + print("✅ Learning updated (satisfaction: \(satisfaction))") + } catch { + print("❌ Failed to update learning: \(error)") + } + } + + /// Save learned state to persistent storage + func saveRuvectorState() async { + guard ruvectorBridge.isReady else { return } + + do { + let stateData = try await ruvectorBridge.saveState() + + // Save to UserDefaults or SwiftData + UserDefaults.standard.set(stateData, forKey: "ruvector_state") + + print("✅ Ruvector state saved (\(stateData.count) bytes)") + } catch { + print("❌ Failed to save Ruvector state: \(error)") + } + } + + /// Load previously saved state + func loadRuvectorState() async { + guard ruvectorBridge.isReady else { return } + guard let stateData = UserDefaults.standard.data(forKey: "ruvector_state") else { + print("ℹ️ No saved Ruvector state found") + return + } + + do { + try await ruvectorBridge.loadState(stateData) + print("✅ Ruvector state loaded (\(stateData.count) bytes)") + } catch { + print("❌ Failed to load Ruvector state: \(error)") + } + } +} + +// MARK: - Modified RecommendationEngine + +/* + Add these properties to RecommendationEngine class: + + class RecommendationEngine { + // Existing properties... + + // NEW: Ruvector integration + private let ruvectorBridge = RuvectorBridge() + private let arwService = ARWService() + private let localStore = LocalStore() + + init(catalog: [MediaItem] = MediaItem.samples) { + self.catalog = catalog + + // Initialize Ruvector asynchronously + Task { + await initializeRuvector() + await loadRuvectorState() + } + } + + // Update existing generateRecommendations to use hybrid approach: + func generateRecommendations( + mood: MoodState, + preferences: UserPreferences, + limit: Int = 5 + ) async -> [MediaItem] { + let context = VibeContext( + mood: mood, + biometrics: getCurrentBiometrics(), + keywords: mood.recommendationHint.keywords + ) + + return await getHybridRecommendations(for: context, limit: limit) + } + } + */ diff --git a/apps/vibecheck-ios/VibeCheck/Engine/RecommendationEngine.swift b/apps/vibecheck-ios/VibeCheck/Engine/RecommendationEngine.swift new file mode 100644 index 00000000..b1f6d68f --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Engine/RecommendationEngine.swift @@ -0,0 +1,269 @@ +import Foundation +import Observation + +@Observable +class RecommendationEngine { + + var recommendations: [MediaItem] = [] + var isLoading = false + + private let catalog: [MediaItem] + + init(catalog: [MediaItem] = MediaItem.samples) { + self.catalog = catalog + } + + /// Generate recommendations based on mood and preferences + func generateRecommendations( + mood: MoodState, + preferences: UserPreferences, + limit: Int = 5 + ) -> [MediaItem] { + + let hint = mood.recommendationHint + + // Step 1: Filter by mood hint + var candidates = catalog.filter { item in + matchesMoodHint(item: item, hint: hint) + } + + // Step 2: Filter by user preferences + candidates = candidates.filter { item in + // Exclude avoided genres + let hasAvoidedGenre = item.genres.contains { preferences.avoidGenres.contains($0) } + if hasAvoidedGenre { return false } + + // Exclude avoided titles + if preferences.avoidTitles.contains(item.id) { return false } + + // Filter by available platforms + if !preferences.subscriptions.isEmpty { + let hasAvailablePlatform = item.platforms.contains { preferences.subscriptions.contains($0) } + if !hasAvailablePlatform { return false } + } + + // Filter by runtime preferences + if let minRuntime = preferences.preferredMinRuntime, item.runtime < minRuntime { + return false + } + if let maxRuntime = preferences.preferredMaxRuntime, item.runtime > maxRuntime { + return false + } + + return true + } + + // Step 3: Score remaining candidates + let scored = candidates.map { item -> (MediaItem, Double) in + let score = scoreItem(item: item, mood: mood, preferences: preferences) + return (item, score) + } + + // Step 4: Sort by score and limit + let sorted = scored.sorted { $0.1 > $1.1 } + return Array(sorted.prefix(limit).map { $0.0 }) + } + + // MARK: - Mood Matching + + private func matchesMoodHint(item: MediaItem, hint: String) -> Bool { + switch hint { + case "comfort": + // Feel-good, familiar, rewatchable content + return item.tone.contains("feel-good") || + item.tone.contains("heartwarming") || + (item.isRewatch && item.rating ?? 0 > 7.5) || + (item.genres.contains("comedy") && item.intensity < 0.6) + + case "gentle": + // Low intensity, not stressful + return item.intensity < 0.4 && + !item.genres.contains("thriller") && + !item.genres.contains("horror") && + !item.tone.contains("intense") + + case "light": + // Comedies, short content, easy watching + return item.genres.contains("comedy") || + item.tone.contains("light") || + item.runtime < 35 + + case "engaging": + // Moderate intensity, interesting but not exhausting + return item.intensity >= 0.4 && + item.intensity < 0.8 && + (item.genres.contains("drama") || item.genres.contains("sci-fi") || item.genres.contains("history")) + + case "exciting": + // High energy, action-packed + return item.genres.contains("action") || + item.genres.contains("adventure") || + item.genres.contains("sci-fi") || + item.tone.contains("exciting") || + item.intensity >= 0.7 + + case "calming": + // Slow, meditative content + return item.tone.contains("slow") || + item.tone.contains("calm") || + item.genres.contains("documentary") || + (item.genres.contains("animation") && item.intensity < 0.4) || + item.intensity < 0.3 + + default: // "balanced" + return true + } + } + + // MARK: - Scoring + + private func scoreItem( + item: MediaItem, + mood: MoodState, + preferences: UserPreferences + ) -> Double { + var score = 0.0 + + // Boost for favorite genres + let favoriteMatches = item.genres.filter { preferences.favoriteGenres.contains($0) }.count + score += Double(favoriteMatches) * 0.3 + + // Boost for rating + if let rating = item.rating { + score += (rating - 7.0) * 0.2 // Boost for ratings above 7 + } + + // Boost for rewatch comfort when mood is comfort + if mood.recommendationHint == "comfort" && item.isRewatch { + score += 0.5 + } + + // Boost for appropriate runtime based on energy + switch mood.energy { + case .exhausted, .low: + if item.runtime < 40 { score += 0.3 } + case .high, .wired: + if item.runtime > 90 { score += 0.2 } + default: + break + } + + // Boost for recent content + let currentYear = Calendar.current.component(.year, from: Date()) + if item.year >= currentYear - 1 { + score += 0.2 + } + + return score + } + + // MARK: - Public API + + func refresh(mood: MoodState, preferences: UserPreferences) { + // Legacy support mapping MoodState to a query if VibeContext isn't available + let query = buildQuery(from: mood) + refresh(query: query, mood: mood, preferences: preferences) + } + + func refresh(context: VibeContext, preferences: UserPreferences) { + // Use the smart keywords from VibePredictor + let query = context.keywords.joined(separator: " ") + " " + context.explanation + refresh(query: query, mood: context.mood, preferences: preferences, context: context) + } + + private func refresh(query: String, mood: MoodState, preferences: UserPreferences, context: VibeContext? = nil) { + isLoading = true + + Task { + // 1. Get local Rule-Based recommendations (fast, safety net) + let ruleRecs = generateRecommendations(mood: mood, preferences: preferences) + + // 2. Get Semantic Vector recommendations (The Sommelier - Local) + let semanticRecs = VectorEmbeddingService.shared.search( + query: query, + in: catalog + ) + + // 2b. Get Remote ARW recommendations (The Media Discovery Backend) + var arwRecs: [MediaItem] = [] + do { + arwRecs = try await ARWService.shared.search(query: query) + } catch { + print("ARW Search failed (backend likely offline): \(error)") + // Continue gracefully + } + + // Combine Semantic Recs: ARW first (remote/richer), then Local + let combinedSemantic = arwRecs + semanticRecs + + // 3. Filter semantic recs by preferences (subscriptions, excluded genres) + let filteredSemantic = combinedSemantic.filter { item in + // Exclude avoided genres/titles + if item.genres.contains(where: { preferences.avoidGenres.contains($0) }) { return false } + if preferences.avoidTitles.contains(item.id) { return false } + + // Platform check (skip for ARW items as they might not have platform data yet, or we assume they are discoverable) + // For local items, check platforms. For ARW, we might want to check if the platform info is available. + // Current ARWService mocks platform as ["arw"]. We can let that pass or check against user subs. + // Implementing permissive check for "arw" platform or matching subs. + if !preferences.subscriptions.isEmpty { + let isARW = item.platforms.contains("arw") + if !isARW && !item.platforms.contains(where: { preferences.subscriptions.contains($0) }) { return false } + } + return true + } + + // 4. Merge (Interleave: Semantic First, then Rule Based) + // De-duplicate + var seenIds = Set() + var merged: [MediaItem] = [] + + let maxCount = max(filteredSemantic.count, ruleRecs.count) + for i in 0.. String { + // "I feel [tired] and [stressed]. Show me [comfort] movies." + var query = "I feel \(mood.energy) energy and \(mood.stress) stress." + query += " Show me \(mood.recommendationHint) movies and TV shows." + return query + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Engine/RuvectorBridge.swift b/apps/vibecheck-ios/VibeCheck/Engine/RuvectorBridge.swift new file mode 100644 index 00000000..85efd5ef --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Engine/RuvectorBridge.swift @@ -0,0 +1,682 @@ +// +// RuvectorBridge.swift +// VibeCheck +// +// Swift wrapper around Ruvector WASM for on-device learning +// Uses WasmKit (https://github.com/swiftwasm/WasmKit) for pure-Swift WASM execution +// + +import Foundation +import WasmKit +import WasmKitWASI + +/// Bridge between VibeCheck and Ruvector WASM recommendation engine +/// +/// Provides privacy-preserving on-device learning with high-performance WASM execution. +/// All learning happens locally - zero network requests. +@available(iOS 15.0, *) +class RuvectorBridge { + + // MARK: - Types + + enum RuvectorError: Error, LocalizedError { + case wasmNotLoaded + case invalidPath + case loadFailed(String) + case functionNotFound(String) + case instantiationFailed(String) + + var errorDescription: String? { + switch self { + case .wasmNotLoaded: return "WASM module not loaded" + case .invalidPath: return "Invalid WASM file path" + case .loadFailed(let msg): return "Failed to load WASM: \(msg)" + case .functionNotFound(let name): return "Function '\(name)' not found in WASM exports" + case .instantiationFailed(let msg): return "WASM instantiation failed: \(msg)" + } + } + } + + // MARK: - Properties + + private var engine: Engine? + private var store: Store? + private var wasmModule: Module? + private var wasmInstance: Instance? + + /// Whether the WASM module is loaded and ready + private(set) var isReady: Bool = false + + /// Time taken to load the WASM module (for benchmarking) + private(set) var loadTimeMs: Double = 0 + + /// List of exported function names (for debugging) + private(set) var exportedFunctions: [String] = [] + + // MARK: - Initialization + + init() {} + + // MARK: - Lifecycle + + /// Load the WASM module from a file path + /// + /// - Parameter wasmPath: Path to the .wasm file + /// - Throws: RuvectorError if loading fails + func load(wasmPath: String) async throws { + guard FileManager.default.fileExists(atPath: wasmPath) else { + throw RuvectorError.invalidPath + } + + let startTime = CFAbsoluteTimeGetCurrent() + + do { + // Load WASM bytes + let wasmData = try Data(contentsOf: URL(fileURLWithPath: wasmPath)) + let wasmBytes = Array(wasmData) + + // Create WasmKit engine and store + self.engine = Engine() + self.store = Store(engine: engine!) + + // Parse WASM module using WasmKit's parseWasm function + self.wasmModule = try parseWasm(bytes: wasmBytes) + + // Create WASI bridge to provide system imports (fd_write, random_get, etc.) + // ruvector.wasm requires WASI for I/O and random number generation + let wasi = try WASIBridgeToHost() + var imports = Imports() + wasi.link(to: &imports, store: store!) + + // Instantiate module with WASI imports + self.wasmInstance = try wasmModule!.instantiate(store: store!, imports: imports) + + // Record exported functions for debugging + self.exportedFunctions = wasmInstance!.exports.map { $0.0 } + + // VERIFICATION: Actually call a WASM function to confirm execution works + // This catches issues where module loads but functions trap + try verifyWASMExecution() + + // INITIALIZATION: Call rec_init and ios_learner_init to enable subsystems + // This makes bench_dot_product and ios_get_energy work + try initializeSubsystems() + + // Calculate load time + self.loadTimeMs = (CFAbsoluteTimeGetCurrent() - startTime) * 1000 + + self.isReady = true + print("✅ RuvectorBridge: WASM loaded and verified in \(String(format: "%.1f", loadTimeMs))ms") + print(" Exports: \(exportedFunctions.joined(separator: ", "))") + + } catch let error as RuvectorError { + throw error + } catch { + throw RuvectorError.loadFailed(error.localizedDescription) + } + } + + /// Load the WASM module from bundle + func loadFromBundle() async throws { + guard let path = Bundle.main.path(forResource: "ruvector", ofType: "wasm") else { + throw RuvectorError.invalidPath + } + try await load(wasmPath: path) + } + + // MARK: - Verification & Initialization + + /// Initialize the recommendation engine subsystems + /// Must be called after instantiation to enable benchmark functions + /// + /// NOTE: Based on WASM function signature analysis: + /// - init(i32, i32) -> i32: Requires memory pointers, skip for now + /// - rec_init(i32, i32) -> i32: Requires memory pointers, skip for now + /// - ios_learner_init() -> i32: Works! No params needed + private func initializeSubsystems() throws { + // Skip init() and rec_init() - they require memory pointer arguments + // that we don't have set up yet. The iOS learner works independently. + + // Call ios_learner_init to enable ML inference (no params needed!) + if let learnerInitFunc = getExportedFunction(name: "ios_learner_init") { + do { + _ = try learnerInitFunc.invoke([]) + print(" ✅ ios_learner_init() - ML learner initialized") + } catch { + let trapDesc = String(describing: error) + print(" ⚠️ ios_learner_init() TRAPPED: \(trapDesc)") + } + } + + // app_usage_init() -> i32: Also no params needed + if let appUsageInitFunc = getExportedFunction(name: "app_usage_init") { + do { + _ = try appUsageInitFunc.invoke([]) + print(" ✅ app_usage_init() - App usage tracker initialized") + } catch { + let trapDesc = String(describing: error) + print(" ⚠️ app_usage_init() TRAPPED: \(trapDesc)") + } + } + + // calendar_init() -> i32: Also no params needed + if let calendarInitFunc = getExportedFunction(name: "calendar_init") { + do { + _ = try calendarInitFunc.invoke([]) + print(" ✅ calendar_init() - Calendar learner initialized") + } catch { + let trapDesc = String(describing: error) + print(" ⚠️ calendar_init() TRAPPED: \(trapDesc)") + } + } + } + + /// Verify WASM execution actually works by calling a simple function + /// This catches trap errors early instead of reporting false "load success" + private func verifyWASMExecution() throws { + // Try multiple functions in order of simplicity + let testFunctions = ["has_simd", "get_bridge_info", "ios_learner_iterations"] + + for funcName in testFunctions { + if let testFunc = getExportedFunction(name: funcName) { + do { + _ = try testFunc.invoke([]) + print(" ✅ Verified: \(funcName)() executed successfully") + return // Success! + } catch { + print(" ⚠️ \(funcName)() trapped: \(error)") + // Try next function + } + } + } + + // If all simple functions fail, the WASM is broken + throw RuvectorError.loadFailed("WASM functions trap on execution - module may be corrupted or incompatible") + } + + // MARK: - Export Access + + /// Check if an exported function exists + private func hasExportedFunction(name: String) -> Bool { + guard let instance = wasmInstance else { return false } + if case .function(_) = instance.export(name) { + return true + } + return false + } + + /// Get an exported function by name + private func getExportedFunction(name: String) -> Function? { + guard let instance = wasmInstance else { return nil } + if case .function(let function) = instance.export(name) { + return function + } + return nil + } + + // MARK: - Benchmarking + + /// Benchmark WASM module loading + static func benchmarkLoad(wasmPath: String) async -> (success: Bool, timeMs: Double, error: String?) { + let bridge = RuvectorBridge() + do { + try await bridge.load(wasmPath: wasmPath) + return (true, bridge.loadTimeMs, nil) + } catch { + return (false, 0, error.localizedDescription) + } + } + + /// Benchmark dot product operation (real SIMD-optimized vector math) + /// Uses bench_dot_product from ruvector.wasm - actual hyperbolic embedding math + /// + /// NOTE: bench_dot_product signature is (i32, i32, i32) -> f32 + /// Params are: (ptr_to_vec1, ptr_to_vec2, dimension) - requires memory allocation + /// For now, we use compute_similarity which takes i64 hashes instead + func benchmarkDotProduct(iterations: Int = 100) throws -> Double { + guard isReady else { + throw RuvectorError.wasmNotLoaded + } + + // Use compute_similarity which takes i64 hashes (simpler than memory pointers) + // compute_similarity(i64, i64) -> f32 + if let simFunc = getExportedFunction(name: "compute_similarity") { + let start = CFAbsoluteTimeGetCurrent() + + do { + for _ in 0.. i32 + // hamming_distance(ptr1, ptr2, len) - also needs memory, return -1 + return -1 // Memory-based benchmarks not available yet + } + + /// Benchmark HNSW search operation (nearest neighbor lookup) + /// + /// HNSW function signatures: + /// - hnsw_create(i32, i32, i32, i32) -> i32: (dim, M, ef_construction, distance_type) + /// - hnsw_insert(i64, i32, i32) -> i32: (handle, ptr, len) + /// - hnsw_search(i32, i32, i32, i32, i32, i32) -> i32: complex ptr-based + /// - hnsw_size() -> i32: Works with no params! + /// + /// Since search requires memory pointers, use hnsw_size as a simple benchmark + func benchmarkHNSWSearch(iterations: Int = 10) throws -> Double { + guard isReady else { + throw RuvectorError.wasmNotLoaded + } + + // Use hnsw_size() which requires no params - simple benchmark + guard let sizeFunc = getExportedFunction(name: "hnsw_size") else { + return -1 + } + + let start = CFAbsoluteTimeGetCurrent() + + do { + for _ in 0.. Bool { + if let simdFunc = getExportedFunction(name: "has_simd") { + if let result = try? simdFunc.invoke([]) { + if case .i32(let value) = result.first { + return value != 0 + } + } + } + return false + } + + /// Get bridge info from WASM (version, capabilities) + func getBridgeInfo() -> String? { + if let infoFunc = getExportedFunction(name: "get_bridge_info") { + if let result = try? infoFunc.invoke([]) { + if case .i32(let ptr) = result.first { + return "ptr:\(ptr)" // Would need memory access to read string + } + } + } + return nil + } + + /// Get number of vectors in the HNSW index + func getVectorCount() -> Int { + if let sizeFunc = getExportedFunction(name: "hnsw_size") { + if let result = try? sizeFunc.invoke([]) { + if case .i32(let count) = result.first { + return Int(count) + } + } + } + return 0 + } + + // MARK: - HNSW Vector Operations + + /// Memory allocation tracking for vectors + private static var nextMemoryOffset: Int = 1048576 // Start at 1MB + private static let vectorAlignment: Int = 16 + + /// Insert a vector into the HNSW index + /// - Parameters: + /// - vector: Float array to insert (517 dimensions for combined mood+media) + /// - id: Unique ID for this vector + /// - Returns: true if insertion succeeded + func insertVector(_ vector: [Float], id: Int32) -> Bool { + guard isReady else { return false } + + guard let insertFunc = getExportedFunction(name: "hnsw_insert"), + let memory = getWASMMemory() else { + print(" ⚠️ hnsw_insert not available or no memory") + return false + } + + // Allocate memory for the vector + let vectorBytes = vector.count * MemoryLayout.size + let alignedSize = (vectorBytes + Self.vectorAlignment - 1) & ~(Self.vectorAlignment - 1) + let offset = Self.nextMemoryOffset + Self.nextMemoryOffset += alignedSize + + // Write vector to WASM memory + writeVectorToMemory(memory: memory, vector: vector, offset: offset) + + do { + // Call hnsw_insert(ptr: i64, dim: i32, id: i32) -> i32 + let result = try insertFunc.invoke([ + .i64(UInt64(offset)), + .i32(UInt32(vector.count)), + .i32(UInt32(bitPattern: id)) + ]) + + if case .i32(let status) = result.first { + let success = status == 0 + if success { + print(" ✅ Inserted vector id=\(id) at offset=\(offset)") + } + return success + } + } catch { + print(" 🔴 hnsw_insert failed: \(error)") + } + + return false + } + + /// Search HNSW index for k nearest neighbors + /// - Parameters: + /// - query: Query vector (517 dimensions) + /// - k: Number of neighbors to return + /// - Returns: Array of (id, similarity) tuples + func searchHnsw(query: [Float], k: Int32) -> [(Int32, Float)] { + guard isReady else { return [] } + + guard let searchFunc = getExportedFunction(name: "hnsw_search"), + let memory = getWASMMemory() else { + // Fallback to compute_similarity for basic search + return fallbackSearch(query: query, k: k) + } + + // Allocate memory for query vector + let queryBytes = query.count * MemoryLayout.size + let alignedSize = (queryBytes + Self.vectorAlignment - 1) & ~(Self.vectorAlignment - 1) + let queryOffset = Self.nextMemoryOffset + Self.nextMemoryOffset += alignedSize + + // Allocate memory for results (pairs of i32 id + f32 similarity) + let resultSize = Int(k) * (MemoryLayout.size + MemoryLayout.size) + let resultOffset = Self.nextMemoryOffset + Self.nextMemoryOffset += resultSize + + writeVectorToMemory(memory: memory, vector: query, offset: queryOffset) + + do { + // Call hnsw_search(query_ptr: i64, dim: i32, k: i32) -> i64 (returns result ptr) + let result = try searchFunc.invoke([ + .i64(UInt64(queryOffset)), + .i32(UInt32(query.count)), + .i32(UInt32(k)) + ]) + + if case .i64(let resultPtr) = result.first, resultPtr != 0 { + // Read results from WASM memory + return readSearchResults(memory: memory, offset: Int(resultPtr), count: Int(k)) + } + } catch { + print(" 🔴 hnsw_search failed: \(error)") + } + + return fallbackSearch(query: query, k: k) + } + + /// Fallback search using compute_similarity when HNSW is not populated + private func fallbackSearch(query: [Float], k: Int32) -> [(Int32, Float)] { + guard let simFunc = getExportedFunction(name: "compute_similarity") else { + return [] + } + + // For now, return empty - compute_similarity takes hash values, not vectors + // A proper implementation would need to iterate through stored vectors + return [] + } + + // MARK: - WASM Memory Access + + /// Get the WASM linear memory + private func getWASMMemory() -> Memory? { + guard let instance = wasmInstance else { return nil } + if case .memory(let mem) = instance.export("memory") { + return mem + } + return nil + } + + /// Write a float vector to WASM linear memory using withUnsafeMutableBufferPointer + private func writeVectorToMemory(memory: Memory, vector: [Float], offset: Int) { + let byteCount = vector.count * MemoryLayout.size + memory.withUnsafeMutableBufferPointer(offset: UInt(offset), count: byteCount) { buffer in + vector.withUnsafeBytes { srcBytes in + buffer.copyMemory(from: srcBytes) + } + } + } + + /// Read search results from WASM memory using withUnsafeMutableBufferPointer + private func readSearchResults(memory: Memory, offset: Int, count: Int) -> [(Int32, Float)] { + var results: [(Int32, Float)] = [] + + // Each result is (i32 id, f32 similarity) = 8 bytes + let resultStride = MemoryLayout.size + MemoryLayout.size + let totalBytes = count * resultStride + + memory.withUnsafeMutableBufferPointer(offset: UInt(offset), count: totalBytes) { buffer in + for i in 0..= 0 { // Valid result + results.append((id, sim)) + } + } + } + + return results + } + + /// Legacy benchmark - kept for compatibility but prefers bench_dot_product + func benchmarkSimpleOp(iterations: Int = 1000) throws -> Double { + // Try the real dot product benchmark first + let dotResult = try benchmarkDotProduct(iterations: iterations) + if dotResult >= 0 { + return dotResult + } + + // Fallback to generic benchmark if available + guard isReady else { + throw RuvectorError.wasmNotLoaded + } + + if let benchFunc = getExportedFunction(name: "benchmark") { + let start = CFAbsoluteTimeGetCurrent() + + for _ in 0.. [String] { + return exportedFunctions + } + + // MARK: - On-Device ML Learning + + /// Initialize the iOS learner for on-device personalization + /// Must be called before using learn/predict functions + func initLearner() throws { + guard isReady else { + throw RuvectorError.wasmNotLoaded + } + + if let initFunc = getExportedFunction(name: "ios_learner_init") { + _ = try initFunc.invoke([]) + print("✅ RuvectorBridge: iOS learner initialized") + } else { + throw RuvectorError.functionNotFound("ios_learner_init") + } + } + + /// Learn from health data (HRV, sleep, steps) + /// - Parameters: + /// - hrv: Heart rate variability in ms (typically 20-100ms) + /// - sleepHours: Hours of sleep (0-12+) + /// - steps: Step count (0-30000+) + /// - energyLabel: User-reported energy level (0.0=exhausted, 1.0=wired) + func learnHealth(hrv: Float, sleepHours: Float, steps: Float, energyLabel: Float) throws { + guard isReady else { + throw RuvectorError.wasmNotLoaded + } + + if let learnFunc = getExportedFunction(name: "ios_learn_health") { + // Pass health metrics and user's energy label for supervised learning + _ = try learnFunc.invoke([ + .f32(hrv.bitPattern), + .f32(sleepHours.bitPattern), + .f32(steps.bitPattern), + .f32(energyLabel.bitPattern) + ]) + } else { + throw RuvectorError.functionNotFound("ios_learn_health") + } + } + + /// Predict energy level from current health data + /// + /// Actual WASM signature: ios_get_energy(f32, f32, f32, f32, i32, i32) -> f32 + /// Params appear to be: (hrv, sleepHours, steps, stressLevel, hour, minute) + /// + /// - Parameters: + /// - hrv: Heart rate variability in ms + /// - sleepHours: Hours of sleep + /// - steps: Step count + /// - stressLevel: Stress level 0.0-1.0 (optional, defaults to 0.5) + /// - Returns: Predicted energy level (0.0=exhausted to 1.0=wired) + func predictEnergy(hrv: Float, sleepHours: Float, steps: Float, stressLevel: Float = 0.5) throws -> Float { + guard isReady else { + throw RuvectorError.wasmNotLoaded + } + + if let getEnergyFunc = getExportedFunction(name: "ios_get_energy") { + // Get current hour and minute for time-of-day context + let now = Calendar.current.dateComponents([.hour, .minute], from: Date()) + let hour = UInt32(now.hour ?? 12) + let minute = UInt32(now.minute ?? 0) + + let result = try getEnergyFunc.invoke([ + .f32(hrv.bitPattern), + .f32(sleepHours.bitPattern), + .f32(steps.bitPattern), + .f32(stressLevel.bitPattern), + .i32(hour), + .i32(minute) + ]) + + if case .f32(let bits) = result.first { + return Float(bitPattern: bits) + } + } + + throw RuvectorError.functionNotFound("ios_get_energy") + } + + /// Get the number of training iterations completed + func getLearnerIterations() throws -> Int { + guard isReady else { + throw RuvectorError.wasmNotLoaded + } + + if let iterFunc = getExportedFunction(name: "ios_learner_iterations") { + let result = try iterFunc.invoke([]) + if case .i32(let count) = result.first { + return Int(count) + } + } + + return 0 + } + + /// Check if this is a good time for communication (notifications) + /// Based on learned patterns from location and communication data + func isGoodCommTime() throws -> Bool { + guard isReady else { + throw RuvectorError.wasmNotLoaded + } + + if let commFunc = getExportedFunction(name: "ios_is_good_comm_time") { + let result = try commFunc.invoke([]) + if case .i32(let value) = result.first { + return value != 0 + } + } + + return true // Default to allowing communication + } + + /// Benchmark ML inference performance + /// + /// ios_get_energy signature: (f32, f32, f32, f32, i32, i32) -> f32 + func benchmarkMLInference(iterations: Int = 100) throws -> Double { + guard isReady else { + throw RuvectorError.wasmNotLoaded + } + + guard let getEnergyFunc = getExportedFunction(name: "ios_get_energy") else { + return -1 + } + + // Typical health values for benchmarking + let hrv: Float = 45.0 + let sleep: Float = 7.0 + let steps: Float = 5000.0 + let stress: Float = 0.5 + let hour: UInt32 = 12 + let minute: UInt32 = 0 + + let start = CFAbsoluteTimeGetCurrent() + + do { + for _ in 0.. String { + // In the future, this is where the local LLM would be called. + // For now, we use a sophisticated template system to simulate the agent. + + let mood = context.mood + let reason = context.explanation // e.g., "you haven't slept much" + + // 1. Direct Match Rationale + if item.isRewatch && mood.energy == .low { + return "Since \(reason), rewatching a favorite like \(item.title) is perfect comfort food for your brain." + } + + // 2. Vibe-Based Rationale + switch mood.recommendationHint { + case "comfort": + return "You seem a bit drained. \(item.title) offers the warm, \(item.tone.first ?? "gentle") vibe you need right now." + case "exciting": + return "You've got energy to burn! \(item.title) matches your high revs with its intensity." + case "calming": + return "To help balance your stress, I picked \(item.title)—it's wonderfully \(item.tone.first ?? "chill")." + case "gentle": + return "Because \(reason), I've selected something soft and easy-going." + case "engaging": + return "You're in a balanced spot, so dive into \(item.title) for something truly gripping." + default: + return "Based on your balanced stats, \(item.title) is a top pick for you." + } + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Engine/VectorEmbeddingService.swift b/apps/vibecheck-ios/VibeCheck/Engine/VectorEmbeddingService.swift new file mode 100644 index 00000000..641ff145 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Engine/VectorEmbeddingService.swift @@ -0,0 +1,75 @@ +import Foundation +import NaturalLanguage + +class VectorEmbeddingService { + static let shared = VectorEmbeddingService() + + private let embedding = NLEmbedding.sentenceEmbedding(for: .english) + + // Cache for item embeddings to avoid re-computing + private var itemCache: [String: [Double]] = [:] + + private init() {} + + /// Generate a vector embedding for a given text string + func embed(text: String) -> [Double]? { + return embedding?.vector(for: text) + } + + /// Calculate cosine similarity between two vectors + func cosineSimilarity(_ v1: [Double], _ v2: [Double]) -> Double { + guard v1.count == v2.count else { return 0.0 } + + var dotProduct = 0.0 + var norm1 = 0.0 + var norm2 = 0.0 + + for i in 0.. [MediaItem] { + guard let queryVector = embed(text: query) else { + print("VectorEmbeddingService: Failed to embed query") + return [] + } + + // 1. Ensure all items have embeddings (lazy load) + var enrichedItems = items + for i in 0.. (MediaItem, Double)? in + guard let itemVector = item.semanticVector else { return nil } + let score = cosineSimilarity(queryVector, itemVector) + return (item, score) + } + + // 3. Sort by similarity + let sortedItems = scoredItems.sorted { $0.1 > $1.1 } + + // Debug print + print("--- Semantic Search Results for: '\(query)' ---") + for (item, score) in sortedItems.prefix(5) { + print("[\(String(format: "%.3f", score))] \(item.title)") + } + + return sortedItems.prefix(limit).map { $0.0 } + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Engine/VibePredictor.swift b/apps/vibecheck-ios/VibeCheck/Engine/VibePredictor.swift new file mode 100644 index 00000000..16224211 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Engine/VibePredictor.swift @@ -0,0 +1,304 @@ +import Foundation + +struct VibeContext { + let keywords: [String] + let explanation: String + let mood: MoodState + let mlConfidence: Double // How confident the ML model is (0-1) + let trainingIterations: Int // How many times the model has learned + + // Default initializer for backwards compatibility + init(keywords: [String], explanation: String, mood: MoodState, mlConfidence: Double = 0.0, trainingIterations: Int = 0) { + self.keywords = keywords + self.explanation = explanation + self.mood = mood + self.mlConfidence = mlConfidence + self.trainingIterations = trainingIterations + } +} + +@available(iOS 15.0, *) +class VibePredictor { + + // Singleton for shared WASM bridge + static let shared = VibePredictor() + + // WASM bridge for ML inference + private var bridge: RuvectorBridge? + private var isMLReady = false + + // Fallback thresholds (used when ML not available or untrained) + private let lowSleepThreshold: Double = 6.0 + private let highActivityThreshold: Double = 8000.0 + private let stressHRVThreshold: Double = 40.0 + + // Track training progress + private(set) var trainingIterations: Int = 0 + + init() {} + + // MARK: - ML Initialization + + /// Initialize the WASM ML learner + /// Call this once at app startup + func initializeML() async { + do { + bridge = RuvectorBridge() + try await bridge?.loadFromBundle() + try bridge?.initLearner() + trainingIterations = (try? bridge?.getLearnerIterations()) ?? 0 + isMLReady = true + print("✅ VibePredictor: ML initialized with \(trainingIterations) prior training iterations") + } catch { + print("⚠️ VibePredictor: ML init failed, using rule-based fallback: \(error)") + isMLReady = false + } + } + + // MARK: - Learning + + /// Learn from user feedback about their actual energy level + /// Call this when user corrects or confirms their mood + func learn(hrv: Double?, sleepHours: Double?, steps: Double?, actualEnergy: MoodState.Energy) { + guard isMLReady, let bridge = bridge else { return } + + let hrvFloat = Float(hrv ?? 50.0) + let sleepFloat = Float(sleepHours ?? 7.0) + let stepsFloat = Float(steps ?? 5000.0) + let energyLabel = Float(actualEnergy.fillAmount) // 0.1 to 1.0 + + do { + try bridge.learnHealth(hrv: hrvFloat, sleepHours: sleepFloat, steps: stepsFloat, energyLabel: energyLabel) + trainingIterations = (try? bridge.getLearnerIterations()) ?? trainingIterations + 1 + print("🧠 VibePredictor: Learned from feedback (iteration \(trainingIterations))") + } catch { + print("⚠️ VibePredictor: Learning failed: \(error)") + } + } + + // MARK: - Prediction + + func predictVibe( + hrv: Double?, + sleepHours: Double?, + steps: Double?, + timeOfDay: Date = Date() + ) -> VibeContext { + + // Try ML prediction first + if isMLReady, let bridge = bridge, trainingIterations >= 5 { + return predictVibeML( + bridge: bridge, + hrv: hrv, + sleepHours: sleepHours, + steps: steps, + timeOfDay: timeOfDay + ) + } + + // Fallback to rule-based + return predictVibeRuleBased( + hrv: hrv, + sleepHours: sleepHours, + steps: steps, + timeOfDay: timeOfDay + ) + } + + // MARK: - ML Prediction + + private func predictVibeML( + bridge: RuvectorBridge, + hrv: Double?, + sleepHours: Double?, + steps: Double?, + timeOfDay: Date + ) -> VibeContext { + + let hrvFloat = Float(hrv ?? 50.0) + let sleepFloat = Float(sleepHours ?? 7.0) + let stepsFloat = Float(steps ?? 5000.0) + + var keywords: [String] = [] + var reasons: [String] = [] + + // 1. ML-based energy prediction + var energy: MoodState.Energy = .moderate + var mlConfidence: Double = 0.5 + + do { + let predictedEnergy = try bridge.predictEnergy(hrv: hrvFloat, sleepHours: sleepFloat, steps: stepsFloat) + mlConfidence = min(1.0, Double(trainingIterations) / 20.0) // Confidence grows with training + + // Map 0-1 float to Energy enum + energy = energyFromFloat(predictedEnergy) + + // Generate keywords based on ML prediction + switch energy { + case .exhausted: + keywords.append(contentsOf: ["comfort", "gentle", "familiar", "cozy"]) + reasons.append("your body signals suggest you need rest") + case .low: + keywords.append(contentsOf: ["comfort", "gentle", "slow-paced"]) + reasons.append("you seem a bit tired") + case .moderate: + keywords.append(contentsOf: ["balanced", "popular", "engaging"]) + case .high: + keywords.append(contentsOf: ["action", "adventure", "exciting"]) + reasons.append("you're feeling energized") + case .wired: + keywords.append(contentsOf: ["action", "fast-paced", "intense", "thriller"]) + reasons.append("you've got lots of energy to burn") + } + } catch { + // Fall back to rule-based for energy + energy = energyFromRules(sleepHours: sleepHours, steps: steps) + keywords.append(contentsOf: ["balanced", "popular"]) + } + + // 2. Stress from HRV (still rule-based, could add ios_learn_stress in future) + var stress: MoodState.Stress = .neutral + if let currentHRV = hrv { + if currentHRV < stressHRVThreshold { + stress = .stressed + keywords.append(contentsOf: ["calming", "meditative", "nature"]) + reasons.append("your HRV suggests some stress") + } else if currentHRV > 60 { + stress = .relaxed + keywords.append(contentsOf: ["creative", "complex", "thought-provoking"]) + } + } + + // 3. Time of day adjustments + let hour = Calendar.current.component(.hour, from: timeOfDay) + if hour < 6 || hour > 22 { + keywords.append(contentsOf: ["dreamy", "surreal"]) + } else if hour > 6 && hour < 11 { + keywords.append(contentsOf: ["inspiring", "motivational"]) + } + + // 4. Build explanation + let explanation: String + if reasons.isEmpty { + explanation = "you're in a solid, balanced flow" + } else { + explanation = reasons.joined(separator: " and ") + } + + let mood = MoodState(energy: energy, stress: stress, confidence: mlConfidence) + + return VibeContext( + keywords: Array(Set(keywords)), + explanation: explanation, + mood: mood, + mlConfidence: mlConfidence, + trainingIterations: trainingIterations + ) + } + + // MARK: - Rule-Based Fallback + + private func predictVibeRuleBased( + hrv: Double?, + sleepHours: Double?, + steps: Double?, + timeOfDay: Date + ) -> VibeContext { + + var keywords: [String] = [] + var reasons: [String] = [] + + let sleep = sleepHours ?? 7.5 + let stepCount = steps ?? 0 + + var energy: MoodState.Energy = .moderate + + if sleep < lowSleepThreshold { + energy = .low + keywords.append(contentsOf: ["comfort", "gentle", "familiar", "slow-paced"]) + reasons.append("you didn't get much sleep") + } else if stepCount > highActivityThreshold { + energy = .high + keywords.append(contentsOf: ["action", "adventure", "exciting", "fast-paced"]) + reasons.append("you've been very active today") + } else { + keywords.append(contentsOf: ["balanced", "popular", "engaging"]) + } + + var stress: MoodState.Stress = .neutral + + if let currentHRV = hrv { + if currentHRV < stressHRVThreshold { + stress = .stressed + keywords.append(contentsOf: ["calming", "meditative", "nature", "hopeful"]) + reasons.append("your stress levels seem elevated") + } else if currentHRV > 60 { + stress = .relaxed + keywords.append(contentsOf: ["creative", "complex", "thought-provoking"]) + } + } + + let hour = Calendar.current.component(.hour, from: timeOfDay) + if hour < 6 || hour > 22 { + keywords.append(contentsOf: ["dreamy", "surreal", "dark"]) + } else if hour > 6 && hour < 11 { + keywords.append(contentsOf: ["inspiring", "motivational"]) + } + + let explanation: String + if reasons.isEmpty { + explanation = "you're in a solid, balanced flow" + } else { + explanation = reasons.joined(separator: " and ") + } + + let mood = MoodState(energy: energy, stress: stress, confidence: 0.3) // Low confidence for rules + + return VibeContext( + keywords: Array(Set(keywords)), + explanation: explanation, + mood: mood, + mlConfidence: 0.0, // No ML used + trainingIterations: 0 + ) + } + + // MARK: - Helpers + + private func energyFromFloat(_ value: Float) -> MoodState.Energy { + switch value { + case ..<0.15: return .exhausted + case 0.15..<0.35: return .low + case 0.35..<0.65: return .moderate + case 0.65..<0.85: return .high + default: return .wired + } + } + + private func energyFromRules(sleepHours: Double?, steps: Double?) -> MoodState.Energy { + let sleep = sleepHours ?? 7.5 + let stepCount = steps ?? 0 + + if sleep < lowSleepThreshold { + return .low + } else if stepCount > highActivityThreshold { + return .high + } + return .moderate + } + + // MARK: - Benchmarking + + /// Benchmark ML inference time + func benchmarkMLInference(iterations: Int = 100) async -> Double { + guard isMLReady, let bridge = bridge else { + return -1 + } + + do { + return try bridge.benchmarkMLInference(iterations: iterations) + } catch { + return -1 + } + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Models/MediaItem.swift b/apps/vibecheck-ios/VibeCheck/Models/MediaItem.swift new file mode 100644 index 00000000..aa4d40c2 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Models/MediaItem.swift @@ -0,0 +1,207 @@ +import Foundation + +struct MediaItem: Identifiable, Codable, Hashable { + let id: String + let title: String + let overview: String + let genres: [String] + let tone: [String] + let intensity: Double // 0.0 - 1.0 + let runtime: Int // minutes + let year: Int + let platforms: [String] + let posterPath: String? + let backdropPath: String? + let rating: Double? + let isRewatch: Bool + + // The Movie Sommelier's Note + var sommelierRationale: String? = nil + + var posterURL: URL? { + guard let path = posterPath else { return nil } + return URL(string: "https://image.tmdb.org/t/p/w500\(path)") + } + + var backdropURL: URL? { + guard let path = backdropPath else { return nil } + return URL(string: "https://image.tmdb.org/t/p/w780\(path)") + } + + var formattedRuntime: String { + if runtime < 60 { + return "\(runtime)m" + } else { + let hours = runtime / 60 + let mins = runtime % 60 + return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" + } + } + + init( + id: String, + title: String, + overview: String = "", + genres: [String] = [], + tone: [String] = [], + intensity: Double = 0.5, + runtime: Int = 90, + year: Int = 2024, + platforms: [String] = [], + posterPath: String? = nil, + backdropPath: String? = nil, + rating: Double? = nil, + isRewatch: Bool = false + ) { + self.id = id + self.title = title + self.overview = overview + self.genres = genres + self.tone = tone + self.intensity = intensity + self.runtime = runtime + self.year = year + self.platforms = platforms + self.posterPath = posterPath + self.backdropPath = backdropPath + self.rating = rating + self.isRewatch = isRewatch + } + + // MARK: - Semantic Search + var semanticVector: [Double]? = nil + + /// Text used to generate the semantic embedding (Title + Overview + Tone + Genres) + var embeddingText: String { + let toneStr = tone.joined(separator: ", ") + let genreStr = genres.joined(separator: ", ") + return "\(title). \(overview) Mood: \(toneStr). Genres: \(genreStr)." + } +} + +// MARK: - Sample Data +extension MediaItem { + static let samples: [MediaItem] = [ + MediaItem( + id: "tt1", + title: "The Bear", + overview: "A young chef returns home to run his family's sandwich shop.", + genres: ["drama", "comedy"], + tone: ["intense", "feel-good"], + intensity: 0.7, + runtime: 30, + year: 2024, + platforms: ["hulu"], + rating: 8.9 + ), + MediaItem( + id: "tt2", + title: "Abbott Elementary", + overview: "A group of dedicated teachers navigate a Philadelphia public school.", + genres: ["comedy"], + tone: ["feel-good", "light"], + intensity: 0.3, + runtime: 22, + year: 2024, + platforms: ["hulu", "max"], + rating: 8.2 + ), + MediaItem( + id: "tt3", + title: "Severance", + overview: "Employees undergo a procedure to separate work and personal memories.", + genres: ["thriller", "sci-fi", "drama"], + tone: ["slow", "mysterious"], + intensity: 0.6, + runtime: 55, + year: 2024, + platforms: ["apple"], + rating: 8.7 + ), + MediaItem( + id: "tt4", + title: "Ted Lasso", + overview: "An American football coach leads a British soccer team.", + genres: ["comedy", "drama"], + tone: ["feel-good", "heartwarming"], + intensity: 0.3, + runtime: 45, + year: 2023, + platforms: ["apple"], + rating: 8.8 + ), + MediaItem( + id: "tt5", + title: "Planet Earth III", + overview: "Documentary series exploring Earth's natural wonders.", + genres: ["documentary"], + tone: ["calm", "slow"], + intensity: 0.2, + runtime: 50, + year: 2023, + platforms: ["max", "discovery+"], + rating: 9.2 + ), + MediaItem( + id: "tt6", + title: "John Wick: Chapter 4", + overview: "Legendary hitman John Wick faces his most dangerous adversaries yet.", + genres: ["action", "thriller"], + tone: ["intense", "exciting"], + intensity: 0.9, + runtime: 169, + year: 2023, + platforms: ["prime"], + rating: 7.7 + ), + MediaItem( + id: "tt7", + title: "Slow Horses", + overview: "British intelligence agents who have messed up end up in Slough House.", + genres: ["thriller", "drama"], + tone: ["slow", "witty"], + intensity: 0.5, + runtime: 45, + year: 2024, + platforms: ["apple"], + rating: 8.1 + ), + MediaItem( + id: "tt8", + title: "Only Murders in the Building", + overview: "Three strangers investigate a murder in their apartment building.", + genres: ["comedy", "mystery"], + tone: ["light", "witty"], + intensity: 0.4, + runtime: 35, + year: 2024, + platforms: ["hulu"], + rating: 8.1 + ), + MediaItem( + id: "tt9", + title: "Shogun", + overview: "An English sailor becomes embroiled in political intrigue in feudal Japan.", + genres: ["drama", "history"], + tone: ["epic", "slow"], + intensity: 0.6, + runtime: 60, + year: 2024, + platforms: ["hulu", "fx"], + rating: 8.7 + ), + MediaItem( + id: "tt10", + title: "Spirited Away", + overview: "A young girl enters a world of spirits to save her parents.", + genres: ["animation", "fantasy"], + tone: ["magical", "gentle"], + intensity: 0.4, + runtime: 125, + year: 2001, + platforms: ["max", "netflix"], + rating: 8.6, + isRewatch: true + ) + ] +} diff --git a/apps/vibecheck-ios/VibeCheck/Models/MoodState.swift b/apps/vibecheck-ios/VibeCheck/Models/MoodState.swift new file mode 100644 index 00000000..107fa1a8 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Models/MoodState.swift @@ -0,0 +1,104 @@ +import Foundation + +struct MoodState: Equatable, Codable { + enum Energy: String, CaseIterable, Codable { + case exhausted, low, moderate, high, wired + + var displayName: String { + rawValue.capitalized + } + + var fillAmount: Double { + switch self { + case .exhausted: return 0.1 + case .low: return 0.3 + case .moderate: return 0.5 + case .high: return 0.8 + case .wired: return 1.0 + } + } + } + + enum Stress: String, CaseIterable, Codable { + case relaxed, calm, neutral, tense, stressed + + var displayName: String { + rawValue.capitalized + } + + var fillAmount: Double { + switch self { + case .relaxed: return 0.2 + case .calm: return 0.4 + case .neutral: return 0.5 + case .tense: return 0.7 + case .stressed: return 0.9 + } + } + } + + let energy: Energy + let stress: Stress + let confidence: Double + let timestamp: Date + + init(energy: Energy, stress: Stress, confidence: Double = 0.5, timestamp: Date = Date()) { + self.energy = energy + self.stress = stress + self.confidence = confidence + self.timestamp = timestamp + } + + var recommendationHint: String { + switch (energy, stress) { + case (.exhausted, _), (.low, .stressed): + return "comfort" + case (.low, .relaxed), (.low, .calm): + return "gentle" + case (.moderate, .relaxed), (.moderate, .calm): + return "engaging" + case (.high, .relaxed), (.high, .calm): + return "exciting" + case (_, .stressed), (_, .tense): + return "light" + case (.wired, _): + return "calming" + default: + return "balanced" + } + } + + var moodDescription: String { + switch (energy, stress) { + case (.exhausted, _): return "Wiped Out" + case (.low, .stressed): return "Running on Empty" + case (.low, _): return "Mellow" + case (.moderate, .relaxed): return "Chill" + case (.moderate, .stressed): return "A Bit Wound Up" + case (.moderate, _): return "Balanced" + case (.high, .relaxed): return "Feeling Great" + case (.high, .stressed): return "Wired" + case (.high, _): return "Energetic" + case (.wired, _): return "Buzzing" + } + } + + var moodIcon: String { + switch recommendationHint { + case "comfort": return "heart.circle.fill" + case "gentle": return "leaf.circle.fill" + case "light": return "sun.max.circle.fill" + case "engaging": return "sparkles" + case "exciting": return "bolt.circle.fill" + case "calming": return "moon.circle.fill" + default: return "circle.grid.cross.fill" + } + } + + // Preset moods for quick override + static let tired = MoodState(energy: .low, stress: .neutral, confidence: 1.0) + static let stressed = MoodState(energy: .moderate, stress: .stressed, confidence: 1.0) + static let energetic = MoodState(energy: .high, stress: .relaxed, confidence: 1.0) + static let chill = MoodState(energy: .moderate, stress: .calm, confidence: 1.0) + static let `default` = MoodState(energy: .moderate, stress: .neutral, confidence: 0.3) +} diff --git a/apps/vibecheck-ios/VibeCheck/Resources/Info.plist b/apps/vibecheck-ios/VibeCheck/Resources/Info.plist new file mode 100644 index 00000000..948d8106 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Resources/Info.plist @@ -0,0 +1,59 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSHealthShareUsageDescription + VibeCheck reads your health data locally to understand your current energy and stress levels. This data never leaves your device and is not shared with any servers. + NSHealthUpdateUsageDescription + VibeCheck does not write any health data. + NSCalendarsUsageDescription + VibeCheck can optionally check your calendar locally to understand your context (solo night vs social plans). This data never leaves your device. + + diff --git a/apps/vibecheck-ios/VibeCheck/Resources/VibeCheck.entitlements b/apps/vibecheck-ios/VibeCheck/Resources/VibeCheck.entitlements new file mode 100644 index 00000000..e1d613e0 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Resources/VibeCheck.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + + diff --git a/apps/vibecheck-ios/VibeCheck/Resources/ruvector.wasm b/apps/vibecheck-ios/VibeCheck/Resources/ruvector.wasm new file mode 100755 index 00000000..e78db9dd Binary files /dev/null and b/apps/vibecheck-ios/VibeCheck/Resources/ruvector.wasm differ diff --git a/apps/vibecheck-ios/VibeCheck/Services/InteractionService.swift b/apps/vibecheck-ios/VibeCheck/Services/InteractionService.swift new file mode 100644 index 00000000..890be897 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Services/InteractionService.swift @@ -0,0 +1,348 @@ +// +// InteractionService.swift +// VibeCheck +// +// Service for managing user interactions with media content. +// Integrates with SwiftData for persistence and LearningMemoryService +// for WASM-based personalization via RuvectorBridge. +// + +import Foundation +import SwiftData + +// MARK: - Interaction Statistics + +/// Statistics about user interactions for analytics and debugging +struct InteractionStats { + let thumbsUpCount: Int + let thumbsDownCount: Int + let seenCount: Int + let totalInteractions: Int + + var likeRatio: Double { + guard thumbsUpCount + thumbsDownCount > 0 else { return 0 } + return Double(thumbsUpCount) / Double(thumbsUpCount + thumbsDownCount) + } +} + +// MARK: - InteractionService + +/// Manages user interactions with media content, providing persistence +/// and integration with the WASM-based learning system. +@available(iOS 17.0, *) +@MainActor +class InteractionService: ObservableObject { + + // MARK: - Properties + + private let modelContext: ModelContext + private var learningMemory: LearningMemoryService? + + /// Published state for UI binding + @Published private(set) var isLearningEnabled: Bool = false + + // MARK: - Initialization + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + /// Initialize with learning memory service for WASM integration + func initializeLearning(with learningMemory: LearningMemoryService) async { + self.learningMemory = learningMemory + self.isLearningEnabled = await learningMemory.isReady + } + + // MARK: - Rating Operations + + /// Rate a media item with thumbs up or down + /// - Parameters: + /// - mediaItem: The media item to rate + /// - rating: The rating (thumbsUp or thumbsDown) + /// - mood: Current mood state for learning context + /// - Returns: The updated interaction + @discardableResult + func rate( + mediaItem: MediaItem, + rating: Rating, + mood: MoodState + ) async throws -> MediaInteraction { + let interaction = try MediaInteraction.findOrCreate( + mediaId: mediaItem.id, + mediaTitle: mediaItem.title, + in: modelContext + ) + + interaction.rating = rating + interaction.moodHint = mood.recommendationHint + + try modelContext.save() + + // Trigger learning in WASM + await triggerLearning( + mediaItem: mediaItem, + feedback: rating.feedbackType, + mood: mood + ) + + return interaction + } + + /// Toggle rating - same rating clears it, different rating switches + /// - Parameters: + /// - mediaItem: The media item + /// - rating: The rating to toggle + /// - mood: Current mood state + /// - Returns: The updated interaction + @discardableResult + func toggleRating( + mediaItem: MediaItem, + rating: Rating, + mood: MoodState + ) async throws -> MediaInteraction { + let interaction = try MediaInteraction.findOrCreate( + mediaId: mediaItem.id, + mediaTitle: mediaItem.title, + in: modelContext + ) + + let previousRating = interaction.rating + interaction.toggleRating(rating) + interaction.moodHint = mood.recommendationHint + + try modelContext.save() + + // Trigger learning based on new state + if let newRating = interaction.rating { + await triggerLearning( + mediaItem: mediaItem, + feedback: newRating.feedbackType, + mood: mood + ) + } else if previousRating != nil { + // Rating was cleared - could trigger neutral feedback + // For now, we don't send feedback when clearing + } + + return interaction + } + + // MARK: - Seen Status Operations + + /// Mark media as seen/watched + /// - Parameters: + /// - mediaItem: The media item + /// - mood: Current mood state + /// - Returns: The updated interaction + @discardableResult + func markAsSeen( + mediaItem: MediaItem, + mood: MoodState + ) async throws -> MediaInteraction { + let interaction = try MediaInteraction.findOrCreate( + mediaId: mediaItem.id, + mediaTitle: mediaItem.title, + in: modelContext + ) + + // Only mark as seen if not already seen + if !interaction.hasSeen { + interaction.markAsSeen() + interaction.moodHint = mood.recommendationHint + + // Create WatchHistory entry + try createWatchHistoryEntry( + mediaItem: mediaItem, + mood: mood + ) + + try modelContext.save() + + // Trigger learning with .watched feedback + await triggerLearning( + mediaItem: mediaItem, + feedback: .watched, + mood: mood + ) + } + + return interaction + } + + /// Mark media as unseen + /// - Parameter mediaItem: The media item + /// - Returns: The updated interaction + @discardableResult + func markAsUnseen(mediaItem: MediaItem) async throws -> MediaInteraction { + let interaction = try MediaInteraction.findOrCreate( + mediaId: mediaItem.id, + mediaTitle: mediaItem.title, + in: modelContext + ) + + interaction.markAsUnseen() + + // Remove WatchHistory entry + try removeWatchHistoryEntry(mediaId: mediaItem.id) + + try modelContext.save() + + return interaction + } + + /// Toggle seen status + /// - Parameters: + /// - mediaItem: The media item + /// - mood: Current mood state (used when marking as seen) + /// - Returns: The updated interaction + @discardableResult + func toggleSeen( + mediaItem: MediaItem, + mood: MoodState + ) async throws -> MediaInteraction { + let interaction = try MediaInteraction.find( + mediaId: mediaItem.id, + in: modelContext + ) + + if let existing = interaction, existing.hasSeen { + return try await markAsUnseen(mediaItem: mediaItem) + } else { + return try await markAsSeen(mediaItem: mediaItem, mood: mood) + } + } + + // MARK: - Fetch Operations + + /// Get interaction for a specific media item + func getInteraction(for mediaId: String) async throws -> MediaInteraction? { + return try MediaInteraction.find(mediaId: mediaId, in: modelContext) + } + + /// Get interactions for multiple media items + func getInteractions(for mediaIds: [String]) async throws -> [MediaInteraction] { + return try MediaInteraction.fetchMultiple(mediaIds: mediaIds, in: modelContext) + } + + /// Get all liked (thumbs up) interactions + func getAllLiked() async throws -> [MediaInteraction] { + return try MediaInteraction.fetchByRating(.thumbsUp, in: modelContext) + } + + /// Get all disliked (thumbs down) interactions + func getAllDisliked() async throws -> [MediaInteraction] { + return try MediaInteraction.fetchByRating(.thumbsDown, in: modelContext) + } + + /// Get all seen interactions + func getAllSeen() async throws -> [MediaInteraction] { + return try MediaInteraction.fetchSeen(in: modelContext) + } + + // MARK: - Statistics + + /// Get learning statistics + func getLearningStats() async -> InteractionStats { + do { + let liked = try await getAllLiked() + let disliked = try await getAllDisliked() + let seen = try await getAllSeen() + + // Calculate total unique interactions + let allDescriptor = FetchDescriptor() + let total = try modelContext.fetchCount(allDescriptor) + + return InteractionStats( + thumbsUpCount: liked.count, + thumbsDownCount: disliked.count, + seenCount: seen.count, + totalInteractions: total + ) + } catch { + print("❌ InteractionService: Failed to get stats: \(error)") + return InteractionStats( + thumbsUpCount: 0, + thumbsDownCount: 0, + seenCount: 0, + totalInteractions: 0 + ) + } + } + + // MARK: - WASM Learning Integration + + /// Trigger learning in the WASM-based LearningMemoryService + private func triggerLearning( + mediaItem: MediaItem, + feedback: FeedbackType, + mood: MoodState + ) async { + guard let learningMemory = learningMemory, await learningMemory.isReady else { + print("⚠️ InteractionService: Learning not available") + return + } + + do { + _ = try await learningMemory.recordFeedback( + mood: mood, + mediaItem: mediaItem, + feedback: feedback, + watchDuration: nil, + completion: feedback == .watched ? 0.7 : nil + ) + print("✅ InteractionService: Recorded \(feedback.rawValue) for '\(mediaItem.title)'") + } catch { + print("❌ InteractionService: Learning failed: \(error)") + } + } + + // MARK: - WatchHistory Integration + + /// Create WatchHistory entry when marking as seen + private func createWatchHistoryEntry( + mediaItem: MediaItem, + mood: MoodState + ) throws { + // Check if entry already exists + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == mediaItem.id } + ) + + if try modelContext.fetch(descriptor).isEmpty { + let historyEntry = WatchHistory( + mediaId: mediaItem.id, + mediaTitle: mediaItem.title, + completionPercent: 1.0, // Marked as seen = completed + moodHint: mood.recommendationHint + ) + modelContext.insert(historyEntry) + } + } + + /// Remove WatchHistory entry when marking as unseen + private func removeWatchHistoryEntry(mediaId: String) throws { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == mediaId } + ) + + let entries = try modelContext.fetch(descriptor) + for entry in entries { + modelContext.delete(entry) + } + } +} + +// MARK: - Environment Key + +@available(iOS 17.0, *) +struct InteractionServiceKey: EnvironmentKey { + static let defaultValue: InteractionService? = nil +} + +@available(iOS 17.0, *) +extension EnvironmentValues { + var interactionService: InteractionService? { + get { self[InteractionServiceKey.self] } + set { self[InteractionServiceKey.self] = newValue } + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Services/LearningMemory.swift b/apps/vibecheck-ios/VibeCheck/Services/LearningMemory.swift new file mode 100644 index 00000000..75b1424d --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Services/LearningMemory.swift @@ -0,0 +1,497 @@ +// +// LearningMemory.swift +// VibeCheck +// +// Learning Memory system for storing (mood, media, feedback) tuples +// in a WASM HNSW vector index for on-device personalized recommendations. +// + +import Foundation + +// MARK: - Feedback Types + +/// Types of user feedback on media content +enum FeedbackType: String, Codable, CaseIterable { + case liked // User explicitly liked (thumbs up, heart) + case disliked // User explicitly disliked (thumbs down) + case watched // User watched significant portion (>70%) + case skipped // User skipped within first 5 minutes + case completed // User finished the entire content + case abandoned // User stopped mid-way (30-70%) + + /// Numeric score for learning (-1.0 to 1.0) + var learningScore: Float { + switch self { + case .liked: return 1.0 + case .completed: return 0.8 + case .watched: return 0.5 + case .abandoned: return -0.2 + case .skipped: return -0.5 + case .disliked: return -1.0 + } + } + + /// Weight for embedding influence + var embeddingWeight: Float { + switch self { + case .liked: return 1.5 + case .completed: return 1.2 + case .watched: return 1.0 + case .abandoned: return 0.5 + case .skipped: return 0.3 + case .disliked: return 0.2 + } + } +} + +// MARK: - Mood Context Embedding + +/// Captures mood state as a normalized vector component +struct MoodEmbedding: Codable, Equatable { + let energy: Float // 0.0 (exhausted) to 1.0 (wired) + let stress: Float // 0.0 (relaxed) to 1.0 (stressed) + let confidence: Float // Model confidence 0.0 to 1.0 + let timeOfDay: Float // Normalized: 0.0 (midnight) to 1.0 (23:59) + let dayOfWeek: Float // 0.0 (Sunday) to 1.0 (Saturday) + + /// Create from MoodState + init(from mood: MoodState, timestamp: Date = Date()) { + self.energy = Float(mood.energy.fillAmount) + self.stress = Float(mood.stress.fillAmount) + self.confidence = Float(mood.confidence) + + let calendar = Calendar.current + let hour = calendar.component(.hour, from: timestamp) + let minute = calendar.component(.minute, from: timestamp) + self.timeOfDay = Float(hour * 60 + minute) / 1440.0 + + let weekday = calendar.component(.weekday, from: timestamp) + self.dayOfWeek = Float(weekday - 1) / 6.0 + } + + /// Convert to array for vector operations + var asArray: [Float] { + [energy, stress, confidence, timeOfDay, dayOfWeek] + } + + static let dimension: Int = 5 +} + +// MARK: - Learning Memory Entry + +/// A single learning memory entry: (mood, media, feedback) tuple +struct LearningMemoryEntry: Codable, Identifiable { + let id: UUID + let timestamp: Date + + let moodEmbedding: MoodEmbedding + let mediaId: String + let mediaTitle: String + let feedbackType: FeedbackType + + var combinedEmbedding: [Float]? + var hnswId: Int32? + + let mediaGenres: [String] + let mediaTones: [String] + let watchDurationSeconds: Int? + let completionPercent: Double? + + init( + mood: MoodState, + mediaItem: MediaItem, + feedback: FeedbackType, + watchDurationSeconds: Int? = nil, + completionPercent: Double? = nil + ) { + self.id = UUID() + self.timestamp = Date() + self.moodEmbedding = MoodEmbedding(from: mood) + self.mediaId = mediaItem.id + self.mediaTitle = mediaItem.title + self.feedbackType = feedback + self.mediaGenres = mediaItem.genres + self.mediaTones = mediaItem.tone + self.watchDurationSeconds = watchDurationSeconds + self.completionPercent = completionPercent + self.combinedEmbedding = nil + self.hnswId = nil + } + + /// Total embedding dimension: 512 (NLEmbedding) + 5 (mood) = 517 + static let embeddingDimension: Int = 512 + MoodEmbedding.dimension +} + +// MARK: - Similar Experience Result + +struct SimilarExperience { + let entry: LearningMemoryEntry + let similarity: Float + let moodSimilarity: Float + let mediaSimilarity: Float + + var effectiveScore: Float { + similarity * entry.feedbackType.learningScore + } +} + +// MARK: - Learned Preferences + +struct LearnedPreferences { + let genreScores: [String: Float] + let toneScores: [String: Float] + let sampleSize: Int + + var topGenres: [String] { + genreScores.sorted { $0.value > $1.value }.prefix(5).map(\.key) + } + + var topTones: [String] { + toneScores.sorted { $0.value > $1.value }.prefix(5).map(\.key) + } +} + +// MARK: - Statistics + +struct LearningMemoryStats { + let totalEntries: Int + let indexedVectors: Int + let maxCapacity: Int + let feedbackDistribution: [FeedbackType: Int] + let oldestEntry: Date? + let newestEntry: Date? + + var utilizationPercent: Double { + guard maxCapacity > 0 else { return 0 } + return Double(indexedVectors) / Double(maxCapacity) * 100 + } +} + +// MARK: - Errors + +enum LearningMemoryError: Error, LocalizedError { + case notInitialized + case embeddingFailed + case indexInsertFailed + case indexSearchFailed + case wasmMemoryError + + var errorDescription: String? { + switch self { + case .notInitialized: return "Learning memory not initialized" + case .embeddingFailed: return "Failed to generate embedding" + case .indexInsertFailed: return "Failed to insert into HNSW index" + case .indexSearchFailed: return "Failed to search HNSW index" + case .wasmMemoryError: return "WASM memory allocation failed" + } + } +} + +// MARK: - Learning Memory Service + +@available(iOS 15.0, *) +actor LearningMemoryService { + + static let shared = LearningMemoryService() + + private let embeddingService = VectorEmbeddingService.shared + private var ruvectorBridge: RuvectorBridge? + + private var entries: [UUID: LearningMemoryEntry] = [:] + private var isInitialized = false + private var nextHnswId: Int32 = 0 + + private init() {} + + // MARK: - Lifecycle + + func initialize(bridge: RuvectorBridge) async { + self.ruvectorBridge = bridge + self.isInitialized = bridge.isReady + await loadPersistedEntries() + + // Index existing media items on initialization + if isInitialized { + await indexMediaCatalog() + } + } + + var isReady: Bool { isInitialized } + + // MARK: - Index Media Catalog + + /// Index all sample media items into HNSW for search + private func indexMediaCatalog() async { + guard let bridge = ruvectorBridge, bridge.isReady else { return } + + print("📚 LearningMemory: Indexing \(MediaItem.samples.count) media items...") + + for (index, item) in MediaItem.samples.enumerated() { + // Create a neutral mood for catalog indexing + let neutralMood = MoodState(energy: .moderate, stress: .neutral) + + do { + // Generate embedding for this media item + let embedding = try generateCombinedEmbedding( + mood: neutralMood, + mediaItem: item, + feedback: .watched // Neutral weight + ) + + // Insert into HNSW + let success = bridge.insertVector(embedding, id: Int32(index)) + if success { + print(" ✅ Indexed: \(item.title) (id=\(index))") + } + } catch { + print(" ❌ Failed to index \(item.title): \(error)") + } + } + + print("📚 LearningMemory: Catalog indexing complete. Vector count: \(bridge.getVectorCount())") + } + + // MARK: - Record Feedback + + func recordFeedback( + mood: MoodState, + mediaItem: MediaItem, + feedback: FeedbackType, + watchDuration: Int? = nil, + completion: Double? = nil + ) async throws -> LearningMemoryEntry { + + guard isInitialized, let bridge = ruvectorBridge else { + throw LearningMemoryError.notInitialized + } + + var entry = LearningMemoryEntry( + mood: mood, + mediaItem: mediaItem, + feedback: feedback, + watchDurationSeconds: watchDuration, + completionPercent: completion + ) + + // Generate combined embedding + let embedding = try generateCombinedEmbedding( + mood: mood, + mediaItem: mediaItem, + feedback: feedback + ) + entry.combinedEmbedding = embedding + + // Insert into HNSW index + let id = nextHnswId + nextHnswId += 1 + + // Offset by catalog size to avoid ID collisions + let hnswId = id + Int32(MediaItem.samples.count) + let success = bridge.insertVector(embedding, id: hnswId) + + if success { + entry.hnswId = hnswId + entries[entry.id] = entry + await persistEntries() + print("✅ LearningMemory: Recorded \(feedback.rawValue) for '\(mediaItem.title)'") + } else { + throw LearningMemoryError.indexInsertFailed + } + + return entry + } + + // MARK: - Find Similar + + func findSimilarExperiences( + mood: MoodState, + mediaHint: String? = nil, + limit: Int = 10 + ) async throws -> [SimilarExperience] { + + guard isInitialized, let bridge = ruvectorBridge else { + throw LearningMemoryError.notInitialized + } + + // Generate query embedding + let query = try generateQueryEmbedding(mood: mood, mediaHint: mediaHint) + + // Search HNSW index + let results = bridge.searchHnsw(query: query, k: Int32(limit)) + + // Map to experiences + var experiences: [SimilarExperience] = [] + let queryMood = MoodEmbedding(from: mood) + + for (hnswId, similarity) in results { + // Check if this is a catalog item + if hnswId < Int32(MediaItem.samples.count) { + let mediaItem = MediaItem.samples[Int(hnswId)] + // Create synthetic entry for catalog items + let entry = LearningMemoryEntry( + mood: mood, + mediaItem: mediaItem, + feedback: .watched + ) + experiences.append(SimilarExperience( + entry: entry, + similarity: similarity, + moodSimilarity: 1.0, + mediaSimilarity: similarity + )) + } else if let entry = findEntry(byHnswId: hnswId) { + let moodSim = calculateMoodSimilarity(query: queryMood, stored: entry.moodEmbedding) + experiences.append(SimilarExperience( + entry: entry, + similarity: similarity, + moodSimilarity: moodSim, + mediaSimilarity: similarity - moodSim * 0.1 + )) + } + } + + return experiences.sorted { $0.effectiveScore > $1.effectiveScore } + } + + // MARK: - Learned Preferences + + func getLearnedPreferences(for mood: MoodState) async throws -> LearnedPreferences { + let experiences = try await findSimilarExperiences(mood: mood, limit: 50) + + var genreScores: [String: Float] = [:] + var toneScores: [String: Float] = [:] + + for exp in experiences { + let weight = exp.effectiveScore + for genre in exp.entry.mediaGenres { + genreScores[genre, default: 0] += weight + } + for tone in exp.entry.mediaTones { + toneScores[tone, default: 0] += weight + } + } + + return LearnedPreferences( + genreScores: genreScores, + toneScores: toneScores, + sampleSize: experiences.count + ) + } + + // MARK: - Embedding Generation + + private func generateCombinedEmbedding( + mood: MoodState, + mediaItem: MediaItem, + feedback: FeedbackType + ) throws -> [Float] { + + guard let mediaEmbedding = embeddingService.embed(text: mediaItem.embeddingText) else { + throw LearningMemoryError.embeddingFailed + } + + let moodEmbed = MoodEmbedding(from: mood) + let weight = feedback.embeddingWeight + + // Weight and combine + var combined = mediaEmbedding.map { Float($0) * weight } + combined.append(contentsOf: moodEmbed.asArray) + + // Normalize + let norm = sqrt(combined.reduce(0) { $0 + $1 * $1 }) + if norm > 0 { + combined = combined.map { $0 / norm } + } + + return combined + } + + private func generateQueryEmbedding(mood: MoodState, mediaHint: String?) throws -> [Float] { + let mediaVector: [Float] + if let hint = mediaHint, let embedding = embeddingService.embed(text: hint) { + mediaVector = embedding.map { Float($0) } + } else { + mediaVector = [Float](repeating: 0, count: 512) + } + + let moodEmbed = MoodEmbedding(from: mood) + var combined = mediaVector + combined.append(contentsOf: moodEmbed.asArray) + + let norm = sqrt(combined.reduce(0) { $0 + $1 * $1 }) + if norm > 0 { + combined = combined.map { $0 / norm } + } + + return combined + } + + // MARK: - Helpers + + private func findEntry(byHnswId id: Int32) -> LearningMemoryEntry? { + entries.values.first { $0.hnswId == id } + } + + private func calculateMoodSimilarity(query: MoodEmbedding, stored: MoodEmbedding) -> Float { + let q = query.asArray + let s = stored.asArray + + var dot: Float = 0 + var normQ: Float = 0 + var normS: Float = 0 + + for i in 0.. 0 && normS > 0 else { return 0 } + return dot / (sqrt(normQ) * sqrt(normS)) + } + + // MARK: - Persistence + + private let persistenceKey = "learning_memory_entries_v1" + + private func persistEntries() async { + let array = Array(entries.values) + if let data = try? JSONEncoder().encode(array) { + UserDefaults.standard.set(data, forKey: persistenceKey) + } + } + + private func loadPersistedEntries() async { + guard let data = UserDefaults.standard.data(forKey: persistenceKey), + let loaded = try? JSONDecoder().decode([LearningMemoryEntry].self, from: data) else { + return + } + + for entry in loaded { + entries[entry.id] = entry + if let id = entry.hnswId { + nextHnswId = max(nextHnswId, id + 1) + } + } + } + + // MARK: - Statistics + + var statistics: LearningMemoryStats { + let feedbackCounts = Dictionary( + grouping: entries.values, + by: { $0.feedbackType } + ).mapValues { $0.count } + + let vectorCount = ruvectorBridge?.getVectorCount() ?? 0 + + return LearningMemoryStats( + totalEntries: entries.count, + indexedVectors: vectorCount, + maxCapacity: 7000, // Approx max in 16MB WASM memory + feedbackDistribution: feedbackCounts, + oldestEntry: entries.values.map(\.timestamp).min(), + newestEntry: entries.values.map(\.timestamp).max() + ) + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/BenchmarkView.swift b/apps/vibecheck-ios/VibeCheck/Views/BenchmarkView.swift new file mode 100644 index 00000000..f0eaf309 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/BenchmarkView.swift @@ -0,0 +1,602 @@ +import SwiftUI +import SwiftData +import NaturalLanguage +import WasmKit + +struct BenchmarkResult: Identifiable { + let id = UUID() + let name: String + let target: String + let actual: String + let status: Status + let isReal: Bool // Indicates if this is a real measurement vs simulated + + enum Status { + case pass, slow, fail + + var icon: String { + switch self { + case .pass: return "checkmark.circle.fill" + case .slow: return "exclamationmark.triangle.fill" + case .fail: return "xmark.circle.fill" + } + } + + var color: Color { + switch self { + case .pass: return .green + case .slow: return .yellow + case .fail: return .red + } + } + } + + init(name: String, target: String, actual: String, status: Status, isReal: Bool = true) { + self.name = name + self.target = target + self.actual = actual + self.status = status + self.isReal = isReal + } +} + +struct BenchmarkView: View { + @Environment(\.modelContext) private var modelContext + @Query private var moodLogs: [MoodLog] + @Query private var watchHistory: [WatchHistory] + @Query private var watchlistItems: [WatchlistItem] + + @State private var results: [BenchmarkResult] = [] + @State private var isRunning = false + + /// Build identifier - increment when making changes to verify deployment + /// Format: v{version}.{build}-{revision} + private let buildIdentifier = "v1.0.1-r16" // LearningMemory + HNSW integration + @State private var memoryUsage: String = "—" + @State private var totalTime: String = "—" + @State private var vectorCount: Int = 0 + + var body: some View { + List { + // Data Context Section + Section { + StatRow(label: "Sample Media Items", value: "\(MediaItem.samples.count)", icon: "film") + StatRow(label: "Mood Logs", value: "\(moodLogs.count)", icon: "heart.text.square") + StatRow(label: "Watch History", value: "\(watchHistory.count)", icon: "clock.arrow.circlepath") + StatRow(label: "Watchlist Items", value: "\(watchlistItems.count)", icon: "bookmark") + StatRow(label: "Mood States", value: "\(MoodState.Energy.allCases.count * MoodState.Stress.allCases.count)", icon: "brain.head.profile") + StatRow(label: "Recommendation Hints", value: "7", icon: "sparkles") + StatRow(label: "Vector Embeddings (HNSW)", value: vectorCount > 0 ? "\(vectorCount)" : "—", icon: "arrow.triangle.branch") + } header: { + Text("Data Context") + } footer: { + Text("Records in the system that benchmarks operate on") + } + + Section { + HStack { + Text("Total Time") + Spacer() + Text(totalTime) + .monospacedDigit() + .foregroundStyle(.secondary) + } + + HStack { + Text("Memory Usage") + Spacer() + Text(memoryUsage) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } header: { + Text("Summary") + } + + Section { + if results.isEmpty && !isRunning { + Text("Tap 'Run Benchmarks' to start") + .foregroundStyle(.secondary) + } else { + ForEach(results) { result in + HStack { + Image(systemName: result.status.icon) + .foregroundStyle(result.status.color) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(result.name) + .font(.subheadline) + if !result.isReal { + Text("(sim)") + .font(.caption2) + .foregroundStyle(.orange) + } + } + Text("Target: \(result.target)") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(result.actual) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(result.status.color) + } + } + } + } header: { + Text("Results") + } footer: { + if !results.isEmpty { + Text("All benchmarks are real measurements except those marked (sim)") + .font(.caption2) + } + } + + Section { + Button { + runBenchmarks() + } label: { + HStack { + Spacer() + if isRunning { + ProgressView() + .padding(.trailing, 8) + Text("Running...") + } else { + Image(systemName: "play.fill") + .padding(.trailing, 4) + Text("Run Benchmarks") + } + Spacer() + } + } + .disabled(isRunning) + } footer: { + Text("Build: \(buildIdentifier)") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 8) + } + } + .navigationTitle("Performance") + .navigationBarTitleDisplayMode(.inline) + } + + private func runBenchmarks() { + isRunning = true + results = [] + + Task { + let startTime = CFAbsoluteTimeGetCurrent() + var benchmarkResults: [BenchmarkResult] = [] + + // 1. NLEmbedding Load Time (REAL - Apple's on-device NLP) + let nlStart = CFAbsoluteTimeGetCurrent() + let embedding = NLEmbedding.sentenceEmbedding(for: .english) + let nlLoaded = embedding != nil + let nlTime = (CFAbsoluteTimeGetCurrent() - nlStart) * 1000 + benchmarkResults.append(BenchmarkResult( + name: "NLEmbedding Load", + target: "<100ms", + actual: nlLoaded ? String(format: "%.1fms", nlTime) : "FAILED", + status: nlLoaded ? (nlTime < 100 ? .pass : (nlTime < 200 ? .slow : .fail)) : .fail, + isReal: true + )) + + // 2. Semantic Vector Generation (REAL - generates actual embedding) + let vectorService = VectorEmbeddingService.shared + let vectorStart = CFAbsoluteTimeGetCurrent() + var vectorSuccess = false + for _ in 0..<10 { + if let _ = vectorService.embed(text: "I want a relaxing comedy movie") { + vectorSuccess = true + } + } + let vectorTime = (CFAbsoluteTimeGetCurrent() - vectorStart) * 1000 / 10 + benchmarkResults.append(BenchmarkResult( + name: "Vector Embedding", + target: "<10ms/op", + actual: vectorSuccess ? String(format: "%.2fms", vectorTime) : "FAILED", + status: vectorSuccess ? (vectorTime < 10 ? .pass : (vectorTime < 50 ? .slow : .fail)) : .fail, + isReal: true + )) + + // 3. Semantic Search (REAL - searches MediaItem.samples) + let searchStart = CFAbsoluteTimeGetCurrent() + let searchResults = vectorService.search( + query: "feel-good comedy for tired evening", + in: MediaItem.samples, + limit: 5 + ) + let searchTime = (CFAbsoluteTimeGetCurrent() - searchStart) * 1000 + benchmarkResults.append(BenchmarkResult( + name: "Semantic Search (\(MediaItem.samples.count) items)", + target: "<100ms", + actual: String(format: "%.1fms (%d results)", searchTime, searchResults.count), + status: searchTime < 100 ? .pass : (searchTime < 500 ? .slow : .fail), + isReal: true + )) + + // 4. Mood Classification (Rule-based fallback) + let vibePredictor = VibePredictor() + let moodStart = CFAbsoluteTimeGetCurrent() + for _ in 0..<100 { + _ = vibePredictor.predictVibe( + hrv: 35.0, + sleepHours: 5.5, + steps: 3000.0 + ) + } + let moodTime = (CFAbsoluteTimeGetCurrent() - moodStart) * 1000 / 100 + benchmarkResults.append(BenchmarkResult( + name: "Mood (rule-based)", + target: "<1ms/op", + actual: String(format: "%.3fms", moodTime), + status: moodTime < 1 ? .pass : (moodTime < 5 ? .slow : .fail), + isReal: true + )) + + // 5. Recommendation Engine (REAL - filters and scores) + let recEngine = RecommendationEngine() + let recStart = CFAbsoluteTimeGetCurrent() + for _ in 0..<10 { + _ = recEngine.generateRecommendations( + mood: MoodState(energy: .low, stress: .stressed), + preferences: UserPreferences.default, + limit: 10 + ) + } + let recTime = (CFAbsoluteTimeGetCurrent() - recStart) * 1000 / 10 + benchmarkResults.append(BenchmarkResult( + name: "Rule-Based Recommendations", + target: "<5ms", + actual: String(format: "%.2fms", recTime), + status: recTime < 5 ? .pass : (recTime < 20 ? .slow : .fail), + isReal: true + )) + + // 6. JSON Serialization (REAL - MoodState encode/decode) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + let jsonStart = CFAbsoluteTimeGetCurrent() + for _ in 0..<100 { + if let data = try? encoder.encode(MoodState.default) { + _ = try? decoder.decode(MoodState.self, from: data) + } + } + let jsonTime = (CFAbsoluteTimeGetCurrent() - jsonStart) * 1000 / 100 + benchmarkResults.append(BenchmarkResult( + name: "JSON Serialize/Deserialize", + target: "<1ms/op", + actual: String(format: "%.3fms", jsonTime), + status: jsonTime < 1 ? .pass : (jsonTime < 5 ? .slow : .fail), + isReal: true + )) + + // 7. WASM Module Load (REAL - WasmKit runtime) + let wasmResult = await benchmarkWASMLoad() + benchmarkResults.append(wasmResult) + + // 8. WASM Dot Product (REAL - bench_dot_product from ruvector.wasm) + let wasmDotResult = await benchmarkWASMDotProduct() + benchmarkResults.append(wasmDotResult) + + // 9. WASM HNSW Search (REAL - nearest neighbor lookup) + let wasmHNSWResult = await benchmarkWASMHNSW() + benchmarkResults.append(wasmHNSWResult) + + // 10. WASM ML Inference (REAL - ios_get_energy from ruvector.wasm) + let wasmMLResult = await benchmarkWASMMLInference() + benchmarkResults.append(wasmMLResult) + + // 11. Memory Usage (REAL - actual process memory) + let memoryBytes = getMemoryUsage() + let memoryMB = Double(memoryBytes) / 1_000_000 + benchmarkResults.append(BenchmarkResult( + name: "Memory Usage", + target: "<100MB", + actual: String(format: "%.1fMB", memoryMB), + status: memoryMB < 100 ? .pass : (memoryMB < 200 ? .slow : .fail), + isReal: true + )) + + let totalTimeMs = (CFAbsoluteTimeGetCurrent() - startTime) * 1000 + + await MainActor.run { + results = benchmarkResults + totalTime = String(format: "%.0fms", totalTimeMs) + memoryUsage = String(format: "%.1fMB", memoryMB) + isRunning = false + } + } + } + + private func getMemoryUsage() -> UInt64 { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + + return result == KERN_SUCCESS ? info.resident_size : 0 + } + + // MARK: - WASM Benchmarks (REAL - WasmKit runtime) + + private func benchmarkWASMLoad() async -> BenchmarkResult { + guard let wasmPath = Bundle.main.path(forResource: "ruvector", ofType: "wasm") else { + return BenchmarkResult( + name: "WASM Module Load", + target: "<100ms", + actual: "NOT FOUND", + status: .fail, + isReal: false + ) + } + + let bridge = RuvectorBridge() + let start = CFAbsoluteTimeGetCurrent() + + do { + try await bridge.load(wasmPath: wasmPath) + let loadTime = (CFAbsoluteTimeGetCurrent() - start) * 1000 + + let exports = bridge.listExports() + let simdStatus = bridge.hasSIMD() ? "SIMD" : "scalar" + let exportInfo = " (\(exports.count) fn, \(simdStatus))" + + // Index media items into HNSW if empty + let initialCount = bridge.getVectorCount() + if initialCount == 0 { + print("📚 Indexing \(MediaItem.samples.count) media items into HNSW...") + let vectorService = VectorEmbeddingService.shared + + for (index, item) in MediaItem.samples.enumerated() { + // Generate embedding for this media item + if let embedding = vectorService.embed(text: item.embeddingText) { + let floatVector = embedding.map { Float($0) } + let success = bridge.insertVector(floatVector, id: Int32(index)) + if !success { + print(" ⚠️ Failed to index: \(item.title)") + } + } + } + print("📚 Indexing complete") + } + + // Capture vector count from HNSW index + let count = bridge.getVectorCount() + await MainActor.run { + vectorCount = count + } + + return BenchmarkResult( + name: "WASM Module Load", + target: "<100ms", + actual: String(format: "%.1fms%@", loadTime, exportInfo), + status: loadTime < 100 ? .pass : (loadTime < 500 ? .slow : .fail), + isReal: true + ) + } catch { + return BenchmarkResult( + name: "WASM Module Load", + target: "<100ms", + actual: extractTrapReason(from: error), + status: .fail, + isReal: true + ) + } + } + + private func benchmarkWASMDotProduct() async -> BenchmarkResult { + guard let wasmPath = Bundle.main.path(forResource: "ruvector", ofType: "wasm") else { + return BenchmarkResult( + name: "WASM Dot Product", + target: "<0.1ms/op", + actual: "NO WASM", + status: .fail, + isReal: false + ) + } + + let bridge = RuvectorBridge() + + do { + try await bridge.load(wasmPath: wasmPath) + + // Benchmark the real bench_dot_product function (128-dim vectors) + let opTime = try bridge.benchmarkDotProduct(iterations: 100) + + if opTime < 0 { + return BenchmarkResult( + name: "WASM Dot Product", + target: "<0.1ms/op", + actual: "fn not found", + status: .fail, + isReal: true + ) + } + + return BenchmarkResult( + name: "WASM Dot Product (128-dim)", + target: "<0.1ms/op", + actual: String(format: "%.4fms", opTime), + status: opTime < 0.1 ? .pass : (opTime < 1 ? .slow : .fail), + isReal: true + ) + } catch { + return BenchmarkResult( + name: "WASM Dot Product", + target: "<0.1ms/op", + actual: extractTrapReason(from: error), + status: .fail, + isReal: true + ) + } + } + + private func benchmarkWASMHNSW() async -> BenchmarkResult { + guard let wasmPath = Bundle.main.path(forResource: "ruvector", ofType: "wasm") else { + return BenchmarkResult( + name: "WASM HNSW Search", + target: "<5ms/op", + actual: "NO WASM", + status: .fail, + isReal: false + ) + } + + let bridge = RuvectorBridge() + + do { + try await bridge.load(wasmPath: wasmPath) + + // Benchmark HNSW nearest neighbor search + let opTime = try bridge.benchmarkHNSWSearch(iterations: 10) + + if opTime < 0 { + return BenchmarkResult( + name: "WASM HNSW Search", + target: "<5ms/op", + actual: "fn not found", + status: .slow, // Not a failure, just not available + isReal: true + ) + } + + return BenchmarkResult( + name: "WASM HNSW Search (k=5)", + target: "<5ms/op", + actual: String(format: "%.2fms", opTime), + status: opTime < 5 ? .pass : (opTime < 20 ? .slow : .fail), + isReal: true + ) + } catch { + return BenchmarkResult( + name: "WASM HNSW Search", + target: "<5ms/op", + actual: extractTrapReason(from: error), + status: .fail, + isReal: true + ) + } + } + + private func benchmarkWASMMLInference() async -> BenchmarkResult { + guard let wasmPath = Bundle.main.path(forResource: "ruvector", ofType: "wasm") else { + return BenchmarkResult( + name: "WASM ML Inference", + target: "<1ms/op", + actual: "NO WASM", + status: .fail, + isReal: false + ) + } + + let bridge = RuvectorBridge() + + do { + try await bridge.load(wasmPath: wasmPath) + + // Initialize the learner first + try bridge.initLearner() + + // Benchmark ML inference (ios_get_energy) + let opTime = try bridge.benchmarkMLInference(iterations: 100) + + if opTime < 0 { + return BenchmarkResult( + name: "WASM ML Inference", + target: "<1ms/op", + actual: "fn not found", + status: .slow, + isReal: true + ) + } + + return BenchmarkResult( + name: "WASM ML Inference (energy)", + target: "<1ms/op", + actual: String(format: "%.4fms", opTime), + status: opTime < 1 ? .pass : (opTime < 5 ? .slow : .fail), + isReal: true + ) + } catch { + return BenchmarkResult( + name: "WASM ML Inference", + target: "<1ms/op", + actual: extractTrapReason(from: error), + status: .fail, + isReal: true + ) + } + } +} + +// MARK: - Helper to extract WASM trap reason + +private func extractTrapReason(from error: Error) -> String { + // WasmKit.Trap has a CustomStringConvertible that shows "Trap: " + let description = String(describing: error) + + // Check for known trap patterns + if description.contains("unreachable") { + return "TRAP: unreachable" + } else if description.contains("call stack exhausted") { + return "TRAP: stack overflow" + } else if description.contains("out of bounds memory") { + return "TRAP: memory OOB" + } else if description.contains("integer divide by zero") { + return "TRAP: div by zero" + } else if description.contains("integer overflow") { + return "TRAP: int overflow" + } else if description.contains("indirect call") { + return "TRAP: null call" + } else if description.contains("Trap:") { + // Extract the reason from "Trap: " + if let range = description.range(of: "Trap: ") { + let reason = String(description[range.upperBound...]).prefix(20) + return "TRAP: \(reason)" + } + } + + // Fallback to short description + let shortDesc = error.localizedDescription.prefix(25) + return "ERR: \(shortDesc)" +} + +// MARK: - Stat Row + +struct StatRow: View { + let label: String + let value: String + let icon: String + + var body: some View { + HStack { + Image(systemName: icon) + .foregroundStyle(.secondary) + .frame(width: 24) + Text(label) + Spacer() + Text(value) + .monospacedDigit() + .fontWeight(.medium) + .foregroundStyle(.primary) + } + } +} + +#Preview { + NavigationStack { + BenchmarkView() + } + .modelContainer(for: [MoodLog.self, WatchHistory.self, WatchlistItem.self], inMemory: true) +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/Components/MediaInteractionBar.swift b/apps/vibecheck-ios/VibeCheck/Views/Components/MediaInteractionBar.swift new file mode 100644 index 00000000..2fd7f7dd --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/Components/MediaInteractionBar.swift @@ -0,0 +1,421 @@ +// +// MediaInteractionBar.swift +// VibeCheck +// +// SwiftUI component for thumbs up/down rating and "seen" toggle. +// Integrates with InteractionService for persistence and WASM learning. +// + +import SwiftUI +import SwiftData + +// MARK: - MediaInteractionBar + +/// Compact interaction bar with thumbs up/down and seen toggle +@available(iOS 17.0, *) +struct MediaInteractionBar: View { + let mediaItem: MediaItem + let mood: MoodState + + @Environment(\.modelContext) private var modelContext + @State private var interaction: MediaInteraction? + @State private var isLoading = false + @State private var showFeedback = false + @State private var feedbackMessage = "" + + /// Optional callback when interaction changes + var onInteractionChanged: ((MediaInteraction) -> Void)? + + var body: some View { + HStack(spacing: 16) { + // Thumbs Up + InteractionButton( + icon: Rating.thumbsUp.iconName, + iconOutline: Rating.thumbsUp.iconNameOutline, + isSelected: interaction?.rating == .thumbsUp, + selectedColor: .green, + accessibilityLabel: Rating.thumbsUp.accessibilityLabel + ) { + await toggleRating(.thumbsUp) + } + + // Thumbs Down + InteractionButton( + icon: Rating.thumbsDown.iconName, + iconOutline: Rating.thumbsDown.iconNameOutline, + isSelected: interaction?.rating == .thumbsDown, + selectedColor: .red, + accessibilityLabel: Rating.thumbsDown.accessibilityLabel + ) { + await toggleRating(.thumbsDown) + } + + Spacer() + + // Seen Toggle + SeenToggleButton( + hasSeen: interaction?.hasSeen ?? false + ) { + await toggleSeen() + } + } + .padding(.horizontal, 4) + .task { + await loadInteraction() + } + .overlay { + if showFeedback { + FeedbackToast(message: feedbackMessage) + .transition(.opacity.combined(with: .scale)) + } + } + .animation(.spring(duration: 0.3), value: interaction?.rating) + .animation(.spring(duration: 0.3), value: interaction?.hasSeen) + .animation(.easeOut(duration: 0.2), value: showFeedback) + } + + // MARK: - Actions + + private func loadInteraction() async { + do { + interaction = try MediaInteraction.find( + mediaId: mediaItem.id, + in: modelContext + ) + } catch { + print("❌ MediaInteractionBar: Failed to load interaction: \(error)") + } + } + + private func toggleRating(_ rating: Rating) async { + isLoading = true + defer { isLoading = false } + + do { + let updatedInteraction = try MediaInteraction.findOrCreate( + mediaId: mediaItem.id, + mediaTitle: mediaItem.title, + in: modelContext + ) + + let previousRating = updatedInteraction.rating + updatedInteraction.toggleRating(rating) + updatedInteraction.moodHint = mood.recommendationHint + + try modelContext.save() + interaction = updatedInteraction + + // Trigger WASM learning + await triggerLearning(feedback: rating.feedbackType) + + // Show feedback + if updatedInteraction.rating == nil { + showFeedbackMessage("Rating cleared") + } else if updatedInteraction.rating == .thumbsUp { + showFeedbackMessage("Added to liked") + } else { + showFeedbackMessage("Marked as not for you") + } + + onInteractionChanged?(updatedInteraction) + + // Haptic feedback + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + + } catch { + print("❌ MediaInteractionBar: Failed to toggle rating: \(error)") + } + } + + private func toggleSeen() async { + isLoading = true + defer { isLoading = false } + + do { + let updatedInteraction = try MediaInteraction.findOrCreate( + mediaId: mediaItem.id, + mediaTitle: mediaItem.title, + in: modelContext + ) + + updatedInteraction.toggleSeen() + updatedInteraction.moodHint = mood.recommendationHint + + // Sync with WatchHistory + if updatedInteraction.hasSeen { + try createWatchHistoryEntry() + await triggerLearning(feedback: .watched) + showFeedbackMessage("Marked as seen") + } else { + try removeWatchHistoryEntry() + showFeedbackMessage("Removed from seen") + } + + try modelContext.save() + interaction = updatedInteraction + + onInteractionChanged?(updatedInteraction) + + // Haptic feedback + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + + } catch { + print("❌ MediaInteractionBar: Failed to toggle seen: \(error)") + } + } + + private func triggerLearning(feedback: FeedbackType) async { + // Learning is handled via LearningMemoryService + // This will be wired up in the parent view or via environment + if #available(iOS 15.0, *) { + let learningMemory = LearningMemoryService.shared + if await learningMemory.isReady { + do { + _ = try await learningMemory.recordFeedback( + mood: mood, + mediaItem: mediaItem, + feedback: feedback + ) + } catch { + print("⚠️ MediaInteractionBar: Learning failed: \(error)") + } + } + } + } + + private func createWatchHistoryEntry() throws { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == mediaItem.id } + ) + + if try modelContext.fetch(descriptor).isEmpty { + let entry = WatchHistory( + mediaId: mediaItem.id, + mediaTitle: mediaItem.title, + completionPercent: 1.0, + moodHint: mood.recommendationHint + ) + modelContext.insert(entry) + } + } + + private func removeWatchHistoryEntry() throws { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == mediaItem.id } + ) + + for entry in try modelContext.fetch(descriptor) { + modelContext.delete(entry) + } + } + + private func showFeedbackMessage(_ message: String) { + feedbackMessage = message + showFeedback = true + + Task { + try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 seconds + showFeedback = false + } + } +} + +// MARK: - InteractionButton + +@available(iOS 17.0, *) +private struct InteractionButton: View { + let icon: String + let iconOutline: String + let isSelected: Bool + let selectedColor: Color + let accessibilityLabel: String + let action: () async -> Void + + @State private var isPressed = false + + var body: some View { + Button { + Task { + await action() + } + } label: { + Image(systemName: isSelected ? icon : iconOutline) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(isSelected ? selectedColor : .secondary) + .frame(width: 44, height: 44) + .background( + Circle() + .fill(isSelected ? selectedColor.opacity(0.15) : Color.clear) + ) + .scaleEffect(isPressed ? 0.9 : 1.0) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity) { + // Never completes + } onPressingChanged: { pressing in + withAnimation(.easeInOut(duration: 0.1)) { + isPressed = pressing + } + } + } +} + +// MARK: - SeenToggleButton + +@available(iOS 17.0, *) +private struct SeenToggleButton: View { + let hasSeen: Bool + let action: () async -> Void + + @State private var isPressed = false + + var body: some View { + Button { + Task { + await action() + } + } label: { + HStack(spacing: 6) { + Image(systemName: hasSeen ? "checkmark.circle.fill" : "circle") + .font(.system(size: 16, weight: .medium)) + + Text(hasSeen ? "Seen" : "Mark seen") + .font(.caption) + .fontWeight(.medium) + } + .foregroundStyle(hasSeen ? .green : .secondary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Capsule() + .fill(hasSeen ? Color.green.opacity(0.15) : Color.secondary.opacity(0.1)) + ) + .scaleEffect(isPressed ? 0.95 : 1.0) + } + .buttonStyle(.plain) + .accessibilityLabel(hasSeen ? "Marked as seen" : "Mark as seen") + .accessibilityAddTraits(hasSeen ? .isSelected : []) + .onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity) { + // Never completes + } onPressingChanged: { pressing in + withAnimation(.easeInOut(duration: 0.1)) { + isPressed = pressing + } + } + } +} + +// MARK: - FeedbackToast + +private struct FeedbackToast: View { + let message: String + + var body: some View { + Text(message) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.black.opacity(0.8)) + ) + } +} + +// MARK: - Compact Variant + +/// Smaller variant for use in lists or cards +@available(iOS 17.0, *) +struct MediaInteractionBarCompact: View { + let mediaItem: MediaItem + let mood: MoodState + + @Environment(\.modelContext) private var modelContext + @State private var interaction: MediaInteraction? + + var body: some View { + HStack(spacing: 12) { + // Thumbs Up (compact) + Button { + Task { await toggleRating(.thumbsUp) } + } label: { + Image(systemName: interaction?.rating == .thumbsUp + ? Rating.thumbsUp.iconName + : Rating.thumbsUp.iconNameOutline) + .font(.system(size: 16)) + .foregroundStyle(interaction?.rating == .thumbsUp ? .green : .secondary) + } + .buttonStyle(.plain) + + // Thumbs Down (compact) + Button { + Task { await toggleRating(.thumbsDown) } + } label: { + Image(systemName: interaction?.rating == .thumbsDown + ? Rating.thumbsDown.iconName + : Rating.thumbsDown.iconNameOutline) + .font(.system(size: 16)) + .foregroundStyle(interaction?.rating == .thumbsDown ? .red : .secondary) + } + .buttonStyle(.plain) + + // Seen indicator + if interaction?.hasSeen ?? false { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(.green) + } + } + .task { + interaction = try? MediaInteraction.find(mediaId: mediaItem.id, in: modelContext) + } + } + + private func toggleRating(_ rating: Rating) async { + do { + let updated = try MediaInteraction.findOrCreate( + mediaId: mediaItem.id, + mediaTitle: mediaItem.title, + in: modelContext + ) + updated.toggleRating(rating) + updated.moodHint = mood.recommendationHint + try modelContext.save() + interaction = updated + + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + } catch { + print("❌ MediaInteractionBarCompact: Error: \(error)") + } + } +} + +// MARK: - Preview + +@available(iOS 17.0, *) +#Preview("Interaction Bar") { + VStack(spacing: 20) { + MediaInteractionBar( + mediaItem: MediaItem.samples.first!, + mood: .chill + ) + .padding() + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + MediaInteractionBarCompact( + mediaItem: MediaItem.samples[1], + mood: .chill + ) + .padding() + } + .padding() + .background(Color(.systemGroupedBackground)) +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/Components/MoodMeshBackground.swift b/apps/vibecheck-ios/VibeCheck/Views/Components/MoodMeshBackground.swift new file mode 100644 index 00000000..10ed2fd9 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/Components/MoodMeshBackground.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct MoodMeshBackground: View { + let mood: MoodState + + var body: some View { + // Simplified gradient - MeshGradient may have issues on iOS 26 beta + let colors = moodColors + LinearGradient( + colors: colors.isEmpty ? [.blue, .purple] : [colors[0], colors[colors.count > 1 ? 1 : 0]], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + } + + private func animatedPoints(for date: Date) -> [SIMD2] { + let t = Float(date.timeIntervalSinceReferenceDate) + let drift = Float(0.03) + + return [ + SIMD2(0, 0), + SIMD2(0.5 + sin(t * 0.4) * drift, 0), + SIMD2(1, 0), + SIMD2(0 + cos(t * 0.3) * drift, 0.5), + SIMD2(0.5 + sin(t * 0.5) * drift, 0.5 + cos(t * 0.4) * drift), + SIMD2(1 - cos(t * 0.35) * drift, 0.5), + SIMD2(0, 1), + SIMD2(0.5 + sin(t * 0.45) * drift, 1), + SIMD2(1, 1) + ] + } + + private var moodColors: [Color] { + return AppTheme.colors(for: mood.recommendationHint) + } +} + +#Preview { + MoodMeshBackground(mood: .chill) +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/Components/QuickMoodOverride.swift b/apps/vibecheck-ios/VibeCheck/Views/Components/QuickMoodOverride.swift new file mode 100644 index 00000000..d549a9ea --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/Components/QuickMoodOverride.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct QuickMoodOverride: View { + let onSelect: (MoodState) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Not quite right?") + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + MoodOverrideButton( + label: "Tired", + icon: "moon.zzz.fill", + color: .purple + ) { + onSelect(.tired) + } + + MoodOverrideButton( + label: "Stressed", + icon: "bolt.heart.fill", + color: .red + ) { + onSelect(.stressed) + } + + MoodOverrideButton( + label: "Energetic", + icon: "figure.run", + color: .orange + ) { + onSelect(.energetic) + } + + MoodOverrideButton( + label: "Chill", + icon: "leaf.fill", + color: .green + ) { + onSelect(.chill) + } + } + } + } +} + +struct MoodOverrideButton: View { + let label: String + let icon: String + let color: Color + let action: () -> Void + + @State private var isPressed = false + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(color) + + Text(label) + .font(.caption2) + .fontWeight(.medium) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(.plain) + .scaleEffect(isPressed ? 0.95 : 1) + .animation(.spring(duration: 0.2), value: isPressed) + .sensoryFeedback(.selection, trigger: isPressed) + .onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity) { + } onPressingChanged: { pressing in + isPressed = pressing + } + } +} + +#Preview { + QuickMoodOverride { mood in + print("Selected: \(mood.moodDescription)") + } + .padding() +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/Components/RecommendationCard.swift b/apps/vibecheck-ios/VibeCheck/Views/Components/RecommendationCard.swift new file mode 100644 index 00000000..b0ee639a --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/Components/RecommendationCard.swift @@ -0,0 +1,289 @@ +import SwiftUI + +struct RecommendationCard: View { + let item: MediaItem + let mood: MoodState + + /// Optional callback when user interacts with thumbs up/down or seen toggle + @available(iOS 17.0, *) + var onInteractionChanged: ((MediaInteraction) -> Void)? = nil + + @State private var isPressed = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Hero image area + ZStack(alignment: .bottomLeading) { + // Poster/backdrop + ZStack { + if let url = item.backdropURL ?? item.posterURL { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + .transition(.opacity) + default: + // Loading or error state: show gradient + Rectangle() + .fill( + LinearGradient( + colors: genreColors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + } + } else { + // No logic fallback + Rectangle() + .fill( + LinearGradient( + colors: genreColors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + } + .overlay { + // Genre icon (only show if no image, or maybe always? + // Let's hide it if we have an image to reduce clutter, or keep it subtle) + if item.backdropPath == nil && item.posterPath == nil { + Image(systemName: genreIcon) + .font(.system(size: 50)) + .foregroundStyle(.white.opacity(0.3)) + } + } + + // Gradient overlay + LinearGradient( + colors: [.clear, .black.opacity(0.8)], + startPoint: .center, + endPoint: .bottom + ) + + // Mood match badge + HStack(spacing: 4) { + Image(systemName: "waveform.path.ecg") + Text("Matches your vibe") + .font(.caption2) + .fontWeight(.medium) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: Capsule()) + .padding(12) + } + .frame(height: 160) + .clipped() + + // Content area + VStack(alignment: .leading, spacing: 10) { + // Title + Text(item.title) + .font(.headline) + .lineLimit(1) + + // Metadata row + HStack(spacing: 12) { + Label(item.formattedRuntime, systemImage: "clock") + Label(String(item.year), systemImage: "calendar") + + if let rating = item.rating { + Label(String(format: "%.1f", rating), systemImage: "star.fill") + .foregroundStyle(.yellow) + } + + Spacer() + + // Platform badges + HStack(spacing: -6) { + ForEach(item.platforms.prefix(3), id: \.self) { platform in + PlatformBadge(platform: platform) + } + } + } + .font(.caption) + .foregroundStyle(.secondary) + + // Genre pills + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(item.genres, id: \.self) { genre in + Text(genre.capitalized) + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.quaternary, in: Capsule()) + } + } + } + + // Sommelier Rationale + if let rationale = item.sommelierRationale { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "sparkles.rectangle.stack.fill") // Or a better "Sommelier" icon + .foregroundStyle(.purple) + .font(.caption) + .padding(.top, 2) + + Text(rationale) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 4) + } + + // Interaction Bar (thumbs up/down, seen toggle) + if #available(iOS 17.0, *) { + Divider() + .padding(.top, 8) + + MediaInteractionBar( + mediaItem: item, + mood: mood, + onInteractionChanged: onInteractionChanged + ) + .padding(.top, 8) + } + } + .padding() + } + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20)) + .shadow(color: .black.opacity(0.1), radius: 10, y: 5) + .scaleEffect(isPressed ? 0.97 : 1) + .animation(.spring(duration: 0.2), value: isPressed) + .onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity) { + // Never completes + } onPressingChanged: { pressing in + isPressed = pressing + } + } + + private var genreColors: [Color] { + guard let primaryGenre = item.genres.first?.lowercased() else { + return [.gray, .gray.opacity(0.7)] + } + + switch primaryGenre { + case "comedy": + return [.yellow, .orange] + case "drama": + return [.purple, .indigo] + case "action", "adventure": + return [.red, .orange] + case "thriller", "horror": + return [.gray, .black] + case "sci-fi", "fantasy": + return [.blue, .purple] + case "documentary": + return [.green, .teal] + case "animation": + return [.pink, .purple] + case "romance": + return [.pink, .red] + case "mystery": + return [.indigo, .black] + default: + return [.blue, .cyan] + } + } + + private var genreIcon: String { + guard let primaryGenre = item.genres.first?.lowercased() else { + return "film" + } + + switch primaryGenre { + case "comedy": + return "face.smiling" + case "drama": + return "theatermasks" + case "action": + return "bolt.fill" + case "adventure": + return "map" + case "thriller", "mystery": + return "magnifyingglass" + case "horror": + return "moon.stars" + case "sci-fi": + return "sparkles" + case "fantasy": + return "wand.and.stars" + case "documentary": + return "globe" + case "animation": + return "paintpalette" + case "romance": + return "heart.fill" + case "history": + return "scroll" + default: + return "film" + } + } +} + +struct PlatformBadge: View { + let platform: String + + var body: some View { + Circle() + .fill(platformColor) + .frame(width: 26, height: 26) + .overlay { + Text(platform.prefix(1).uppercased()) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + } + .overlay { + Circle() + .stroke(Color(.systemBackground), lineWidth: 2) + } + } + + private var platformColor: Color { + switch platform.lowercased() { + case "netflix": + return .red + case "hulu": + return .green + case "disney+", "disney": + return .blue + case "max", "hbo": + return .purple + case "prime", "amazon": + return .cyan + case "apple", "apple tv+": + return .gray + case "peacock": + return .yellow + case "paramount+", "paramount": + return .blue + case "discovery+": + return .blue + case "fx": + return .orange + default: + return .secondary + } + } +} + +#Preview { + ScrollView { + VStack(spacing: 20) { + ForEach(MediaItem.samples.prefix(3)) { item in + RecommendationCard(item: item, mood: .chill) + } + } + .padding() + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/Components/VibeHeader.swift b/apps/vibecheck-ios/VibeCheck/Views/Components/VibeHeader.swift new file mode 100644 index 00000000..565383a3 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/Components/VibeHeader.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct VibeHeader: View { + let mood: MoodState? + let isLoading: Bool + + var body: some View { + VStack(spacing: 8) { + if isLoading { + ProgressView() + .controlSize(.regular) + + Text("Checking your vibe...") + .font(.title3) + .foregroundStyle(.secondary) + } else if let mood = mood { + Text("You seem") + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(mood.moodDescription) + .font(.system(size: 34, weight: .bold, design: .rounded)) + .contentTransition(.numericText()) + + // Confidence indicator + if mood.confidence < 0.5 { + HStack(spacing: 4) { + Image(systemName: "questionmark.circle") + Text("Limited health data") + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 4) + } + } else { + Text("Ready to check in") + .font(.title2) + .foregroundStyle(.secondary) + } + } + .animation(.spring, value: mood) + .animation(.spring, value: isLoading) + } +} + +#Preview { + VStack(spacing: 40) { + VibeHeader(mood: nil, isLoading: true) + VibeHeader(mood: .chill, isLoading: false) + VibeHeader(mood: .tired, isLoading: false) + } +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/Components/VibeRing.swift b/apps/vibecheck-ios/VibeCheck/Views/Components/VibeRing.swift new file mode 100644 index 00000000..67d042e2 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/Components/VibeRing.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct VibeRing: View { + let mood: MoodState + @State private var animateRing = false + + var body: some View { + ZStack { + // Background ring + Circle() + .stroke(lineWidth: 20) + .opacity(0.1) + .foregroundStyle(AppTheme.accentGradient) + + // Energy ring (outer) + Circle() + .trim(from: 0, to: animateRing ? mood.energy.fillAmount : 0) + .stroke( + energyGradient, + style: StrokeStyle(lineWidth: 20, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.spring(duration: 1.2, bounce: 0.3), value: animateRing) + + // Inner track + Circle() + .stroke(lineWidth: 12) + .opacity(0.1) + .foregroundStyle(.primary) + .padding(24) + + // Stress ring (inner) + Circle() + .trim(from: 0, to: animateRing ? mood.stress.fillAmount : 0) + .stroke( + stressGradient, + style: StrokeStyle(lineWidth: 12, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .padding(24) + .animation(.spring(duration: 1.0, bounce: 0.3).delay(0.2), value: animateRing) + + // Center content + VStack(spacing: 4) { + Image(systemName: mood.moodIcon) + .font(.system(size: 36, weight: .medium)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(energyGradient) + + Text(mood.recommendationHint.capitalized) + .font(.system(.headline, design: .rounded)) + .fontWeight(.semibold) + .contentTransition(.numericText()) + + Text("mode") + .font(.caption2) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(1) + } + } + .frame(width: 200, height: 200) + .onAppear { + animateRing = true + } + .onChange(of: mood) { _, _ in + // Re-animate when mood changes + animateRing = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + animateRing = true + } + } + // Removed sensoryFeedback - may have issues on iOS 26 beta + } + + private var energyGradient: LinearGradient { + switch mood.energy { + case .exhausted, .low: + return LinearGradient( + colors: [.purple, .indigo], + startPoint: .leading, + endPoint: .trailing + ) + case .moderate: + return LinearGradient( + colors: [.green, .teal], + startPoint: .leading, + endPoint: .trailing + ) + case .high, .wired: + return LinearGradient( + colors: [.orange, .yellow], + startPoint: .leading, + endPoint: .trailing + ) + } + } + + private var stressGradient: LinearGradient { + switch mood.stress { + case .relaxed, .calm: + return LinearGradient( + colors: [.mint, .teal], + startPoint: .leading, + endPoint: .trailing + ) + case .neutral: + return LinearGradient( + colors: [.blue, .cyan], + startPoint: .leading, + endPoint: .trailing + ) + case .tense, .stressed: + return LinearGradient( + colors: [.red, .orange], + startPoint: .leading, + endPoint: .trailing + ) + } + } +} + +#Preview { + VStack(spacing: 40) { + VibeRing(mood: .chill) + VibeRing(mood: .tired) + VibeRing(mood: .energetic) + } + .padding() +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/ForYouView.swift b/apps/vibecheck-ios/VibeCheck/Views/ForYouView.swift new file mode 100644 index 00000000..a9c630a6 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/ForYouView.swift @@ -0,0 +1,518 @@ +import SwiftUI +import SwiftData + +struct ForYouView: View { + @Environment(\.modelContext) private var modelContext + @State private var healthManager = HealthKitManager() + @State private var engine = RecommendationEngine() + @State private var currentMood: MoodState? + @State private var vibeContext: VibeContext? + @State private var selectedItem: MediaItem? + @State private var showingHealthPermission = false + + @State private var familyVibes: [FamilyMemberVibe] = [] + + private let vibePredictor = VibePredictor() + + // ... (rest of the view) + + var body: some View { + NavigationStack { + ZStack { + // ... (background) + if let mood = currentMood { + MoodMeshBackground(mood: mood) + .opacity(0.6) + } + + ScrollView { + LazyVStack(spacing: 24) { + // Header section + VibeHeader(mood: currentMood, isLoading: healthManager.isLoading) + .padding(.top, 20) + + // Vibe ring + if let mood = currentMood { + VibeRing(mood: mood) + .padding(.vertical, 10) + } else if !healthManager.isLoading { + // ... (button) + Button { + Task { await refresh() } + } label: { + Label("Check My Vibe", systemImage: "waveform.path.ecg") + // ... + .font(.headline) + .padding() + .background(.ultraThinMaterial, in: Capsule()) + } + .buttonStyle(.plain) + .padding(.vertical, 20) + } + + // FAMILY VIBES SECTION (NEW) + if !familyVibes.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("Family Sync") + .font(.headline) + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(familyVibes) { member in + VStack(spacing: 8) { + Circle() + .fill(Color(hex: member.colorHex) ?? .gray) + .frame(width: 60, height: 60) + .overlay( + Circle().stroke(.white.opacity(0.5), lineWidth: 2) + ) + .shadow(color: (Color(hex: member.colorHex) ?? .gray).opacity(0.5), radius: 8) + .overlay { + Text(member.vibeKeyword.prefix(1)) + .font(.title2) + .bold() + .foregroundStyle(.white) + } + + Text(member.vibeKeyword) + .font(.caption) + .fontWeight(.medium) + } + } + } + .padding(.horizontal) + } + } + } + + // ... (rest of view) + // Quick mood override + if currentMood != nil { + QuickMoodOverride { newMood in + withAnimation(.spring) { + currentMood = newMood + logMood(newMood) + } + refreshRecommendations() + // CloudKit sync disabled for iOS 26 beta + // publishVibeToCloud(mood: newMood) + } + .padding(.horizontal) + } + + // Recommendations section + // ... + if !engine.recommendations.isEmpty { + Section { + ForEach(engine.recommendations) { item in + RecommendationCard(item: item, mood: currentMood ?? .default) + // ... + .onTapGesture { + selectedItem = item + } + .scrollTransition { content, phase in + content + .opacity(phase.isIdentity ? 1 : 0.7) + .scaleEffect(phase.isIdentity ? 1 : 0.96) + .blur(radius: phase.isIdentity ? 0 : 1) + } + } + } header: { + sectionHeader + } + .padding(.horizontal) + } + + // Privacy note + // ... + privacyNote + .padding(.top, 20) + .padding(.bottom, 40) + } + } + // ... + .scrollIndicators(.hidden) + .refreshable { + await refresh() + } + } + .navigationTitle("For You") + // ... + .task { + await initialize() + } + } + } + + // MARK: - Subviews + + private var sectionHeader: some View { + HStack { + Text("For Your \(currentMood?.recommendationHint.capitalized ?? "") Mood") + .font(.title3) + .fontWeight(.bold) + Spacer() + } + .padding(.top, 8) + } + + private var privacyNote: some View { + HStack(spacing: 8) { + Image(systemName: "lock.shield.fill") + .foregroundStyle(.green) + + Text("Your health data never leaves this device") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + } + + // MARK: - Actions + + private func initialize() async { + guard healthManager.isHealthDataAvailable else { + // Use default mood for simulator/devices without HealthKit + currentMood = .default + refreshRecommendations() + return + } + + do { + try await healthManager.requestAuthorization() + await refresh() + } catch { + print("HealthKit authorization failed: \(error)") + showingHealthPermission = true + // Fall back to default mood + currentMood = .default + refreshRecommendations() + } + } + + private func refresh() async { + await healthManager.fetchCurrentContext() + + // Use the new VibePredictor (Checking biometrics...) + let context = vibePredictor.predictVibe( + hrv: healthManager.currentHRV, + sleepHours: healthManager.lastSleepHours, + steps: healthManager.stepsToday + ) + + await MainActor.run { + withAnimation(.spring) { + currentMood = context.mood + vibeContext = context + } + + logMood(context.mood) + refreshRecommendations() + } + + // SYNC: Publish my vibe and fetch family (disabled for now - CloudKit may crash on iOS 26 beta) + // publishVibeToCloud(mood: context.mood) + // fetchFamilyVibes() + } + + private func publishVibeToCloud(mood: MoodState) { + let memberVibe = FamilyMemberVibe( + id: UUID(), + userID: "Me", // In real app, use CKCurrentUserDefaultName + vibeKeyword: mood.recommendationHint, + vibeEnergy: mood.energy.fillAmount > 0.5 ? "High" : "Low", + vibeStress: mood.stress.fillAmount > 0.5 ? "High" : "Low", + colorHex: "#6366F1", // Default indigo color + lastUpdated: Date() + ) + + CloudKitManager.shared.publishVibe(memberVibe) { result in + if case .failure(let error) = result { + print("Failed to publish vibe: \(error.localizedDescription)") + } + } + } + + private func fetchFamilyVibes() { + CloudKitManager.shared.fetchFamilyVibes { result in + DispatchQueue.main.async { + switch result { + case .success(let vibes): + withAnimation { + // Filter out "Me" to show only others if we had real IDs, + // but for now show everyone to verify it works + self.familyVibes = vibes + } + case .failure(let error): + print("Error fetching family: \(error)") + } + } + } + } + + private func refreshRecommendations() { + guard let context = vibeContext else { + // Use default mood if vibeContext isn't set yet + let defaultContext = VibeContext( + keywords: ["balanced", "popular"], + explanation: "your balanced stats", + mood: currentMood ?? .default + ) + let preferences = UserPreferences.default + engine.refresh(context: defaultContext, preferences: preferences) + return + } + + let preferences = UserPreferences.default + engine.refresh(context: context, preferences: preferences) + } + + private func logMood(_ mood: MoodState) { + let log = MoodLog( + mood: mood, + hrv: healthManager.currentHRV, + sleepHours: healthManager.lastSleepHours, + steps: healthManager.stepsToday + ) + modelContext.insert(log) + } +} + +// Helper for Color Hex +extension Color { + init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + var rgb: UInt64 = 0 + + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { + return nil + } + + self.init( + red: Double((rgb & 0xFF0000) >> 16) / 255.0, + green: Double((rgb & 0x00FF00) >> 8) / 255.0, + blue: Double(rgb & 0x0000FF) / 255.0 + ) + } +} + +// MARK: - Media Detail Sheet + +struct MediaDetailSheet: View { + let item: MediaItem + let mood: MoodState + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Hero + ZStack(alignment: .bottomLeading) { + Rectangle() + .fill( + LinearGradient( + colors: [.purple, .blue], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(height: 200) + + LinearGradient( + colors: [.clear, Color(.systemBackground)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 100) + + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.largeTitle) + .fontWeight(.bold) + + HStack { + Text(String(item.year)) + Text("•") + Text(item.formattedRuntime) + if let rating = item.rating { + Text("•") + Label(String(format: "%.1f", rating), systemImage: "star.fill") + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding() + } + + VStack(alignment: .leading, spacing: 16) { + // Why this recommendation + if let rationale = item.sommelierRationale { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "sparkles.rectangle.stack.fill") + .foregroundStyle(.purple) + .font(.title3) + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 4) { + Text("The Movie Sommelier says:") + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(.purple) + + Text(rationale) + .font(.subheadline) + .foregroundStyle(.primary) + } + } + .padding() + .background(.purple.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) + } else { + // Fallback + HStack(spacing: 8) { + Image(systemName: "waveform.path.ecg") + .foregroundStyle(.green) + Text("Recommended because you're in \(mood.recommendationHint) mode") + .font(.subheadline) + } + .padding() + .background(.green.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) + } + + // Overview + if !item.overview.isEmpty { + Text(item.overview) + .font(.body) + .foregroundStyle(.secondary) + } + + // Platforms + VStack(alignment: .leading, spacing: 8) { + Text("Available on") + .font(.headline) + + HStack(spacing: 12) { + ForEach(item.platforms, id: \.self) { platform in + HStack { + PlatformBadge(platform: platform) + Text(platform.capitalized) + .font(.subheadline) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.quaternary, in: Capsule()) + } + } + } + + // Genres + VStack(alignment: .leading, spacing: 8) { + Text("Genres") + .font(.headline) + + FlowLayout(spacing: 8) { + ForEach(item.genres, id: \.self) { genre in + Text(genre.capitalized) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.quaternary, in: Capsule()) + } + } + } + + // Tone tags + if !item.tone.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Vibe") + .font(.headline) + + FlowLayout(spacing: 8) { + ForEach(item.tone, id: \.self) { tone in + Text(tone.capitalized) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.blue.opacity(0.1), in: Capsule()) + } + } + } + } + + // Interaction Bar (thumbs up/down, seen toggle) + if #available(iOS 17.0, *) { + Divider() + .padding(.vertical, 8) + + VStack(alignment: .leading, spacing: 8) { + Text("Your Rating") + .font(.headline) + + MediaInteractionBar( + mediaItem: item, + mood: mood + ) + } + } + } + .padding() + } + } + } +} + +// MARK: - Flow Layout + +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let result = FlowResult(in: proposal.width ?? 0, subviews: subviews, spacing: spacing) + return result.size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = FlowResult(in: bounds.width, subviews: subviews, spacing: spacing) + for (index, subview) in subviews.enumerated() { + subview.place(at: CGPoint(x: bounds.minX + result.positions[index].x, + y: bounds.minY + result.positions[index].y), + proposal: .unspecified) + } + } + + struct FlowResult { + var size: CGSize = .zero + var positions: [CGPoint] = [] + + init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) { + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if x + size.width > maxWidth, x > 0 { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + + positions.append(CGPoint(x: x, y: y)) + rowHeight = max(rowHeight, size.height) + x += size.width + spacing + } + + self.size = CGSize(width: maxWidth, height: y + rowHeight) + } + } +} + +#Preview { + ForYouView() + .modelContainer(for: [WatchHistory.self, UserPreferences.self, MoodLog.self, WatchlistItem.self, MediaInteraction.self], inMemory: true) +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/SettingsView.swift b/apps/vibecheck-ios/VibeCheck/Views/SettingsView.swift new file mode 100644 index 00000000..faec221e --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/SettingsView.swift @@ -0,0 +1,313 @@ +import SwiftUI +import SwiftData + +struct SettingsView: View { + @Environment(\.modelContext) private var modelContext + @State private var preferences = UserPreferences.default + @State private var showingExportSheet = false + @State private var showingClearDataAlert = false + + let allGenres = ["comedy", "drama", "action", "thriller", "sci-fi", "fantasy", "documentary", "animation", "romance", "horror", "mystery", "adventure"] + let allPlatforms = ["netflix", "hulu", "apple", "max", "prime", "disney", "peacock", "paramount"] + + var body: some View { + NavigationStack { + List { + // Subscriptions + Section("Your Streaming Services") { + ForEach(allPlatforms, id: \.self) { platform in + Toggle(isOn: Binding( + get: { preferences.subscriptions.contains(platform) }, + set: { isOn in + if isOn { + preferences.subscriptions.append(platform) + } else { + preferences.subscriptions.removeAll { $0 == platform } + } + } + )) { + HStack { + PlatformBadge(platform: platform) + Text(platform.capitalized) + } + } + } + } + + // Favorite genres + Section("Favorite Genres") { + ForEach(allGenres, id: \.self) { genre in + Toggle(isOn: Binding( + get: { preferences.favoriteGenres.contains(genre) }, + set: { isOn in + if isOn { + preferences.favoriteGenres.append(genre) + } else { + preferences.favoriteGenres.removeAll { $0 == genre } + } + } + )) { + Text(genre.capitalized) + } + } + } + + // Avoid genres + Section("Genres to Avoid") { + ForEach(allGenres, id: \.self) { genre in + Toggle(isOn: Binding( + get: { preferences.avoidGenres.contains(genre) }, + set: { isOn in + if isOn { + preferences.avoidGenres.append(genre) + // Remove from favorites if added to avoid + preferences.favoriteGenres.removeAll { $0 == genre } + } else { + preferences.avoidGenres.removeAll { $0 == genre } + } + } + )) { + Text(genre.capitalized) + } + .tint(.red) + } + } + + // Privacy + Section("Privacy") { + NavigationLink { + PrivacyDetailView() + } label: { + Label("How Your Data Is Used", systemImage: "lock.shield") + } + + Button(role: .destructive) { + showingClearDataAlert = true + } label: { + Label("Clear All Local Data", systemImage: "trash") + } + } + + // Export + Section("Data Portability") { + Button { + showingExportSheet = true + } label: { + Label("Export My Preferences", systemImage: "square.and.arrow.up") + } + } + + // Developer + Section("Developer") { + NavigationLink { + BenchmarkView() + } label: { + Label("Performance Benchmark", systemImage: "speedometer") + } + } + + // About + Section("About") { + HStack { + Text("Version") + Spacer() + Text("1.0.1-r16") + .foregroundStyle(.secondary) + } + + Link(destination: URL(string: "https://agentics.org/hackathon")!) { + Label("Agentics Hackathon", systemImage: "link") + } + } + } + .navigationTitle("Settings") + .alert("Clear All Data?", isPresented: $showingClearDataAlert) { + Button("Cancel", role: .cancel) { } + Button("Clear", role: .destructive) { + clearAllData() + } + } message: { + Text("This will delete all your mood history, watchlist, and preferences. This cannot be undone.") + } + .sheet(isPresented: $showingExportSheet) { + ExportSheet(preferences: preferences) + } + } + } + + private func clearAllData() { + // Clear would be implemented here + // For now, just reset preferences + preferences = UserPreferences.default + } +} + +struct PrivacyDetailView: View { + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 16) { + Label { + Text("100% On-Device") + .font(.headline) + } icon: { + Image(systemName: "iphone") + .foregroundStyle(.blue) + } + + Text("All your health data and preferences are processed entirely on your iPhone. Nothing is sent to any server.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 8) + } + + Section("What We Access") { + PrivacyRow( + icon: "heart.text.square", + title: "Heart Rate Variability", + description: "Used to estimate your stress level" + ) + + PrivacyRow( + icon: "bed.double", + title: "Sleep Data", + description: "Used to estimate your energy level" + ) + + PrivacyRow( + icon: "figure.walk", + title: "Step Count", + description: "Used to understand your activity level" + ) + + PrivacyRow( + icon: "heart", + title: "Resting Heart Rate", + description: "Secondary stress indicator" + ) + } + + Section("What We Never Do") { + Label("Upload health data to any server", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + Label("Share data with third parties", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + Label("Use data for advertising", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + Label("Retain data after you delete it", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + } + + Section { + Text("You can revoke health access anytime in Settings → Privacy → Health → VibeCheck") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Privacy") + .navigationBarTitleDisplayMode(.inline) + } +} + +struct PrivacyRow: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(.green) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline) + .fontWeight(.medium) + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} + +struct ExportSheet: View { + let preferences: UserPreferences + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack(spacing: 20) { + Image(systemName: "doc.text") + .font(.system(size: 60)) + .foregroundStyle(.blue) + + Text("Export Your Data") + .font(.title2) + .fontWeight(.bold) + + Text("Your preferences will be exported as a JSON file that you can use with other apps or save as a backup.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal) + + // Preview + GroupBox("Preview") { + ScrollView { + Text(exportJSON) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 200) + } + .padding() + + ShareLink(item: exportJSON, preview: SharePreview("VibeCheck Preferences", image: Image(systemName: "doc.text"))) { + Label("Share", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + .padding() + .background(.blue) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(.horizontal) + + Spacer() + } + .padding(.top, 40) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } + + private var exportJSON: String { + let export: [String: Any] = [ + "format": "vibecheck-preferences-v1", + "exported": ISO8601DateFormatter().string(from: Date()), + "favoriteGenres": preferences.favoriteGenres, + "avoidGenres": preferences.avoidGenres, + "subscriptions": preferences.subscriptions + ] + + if let data = try? JSONSerialization.data(withJSONObject: export, options: .prettyPrinted), + let string = String(data: data, encoding: .utf8) { + return string + } + return "{}" + } +} + +#Preview { + SettingsView() + .modelContainer(for: [UserPreferences.self], inMemory: true) +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/VibeCheckView.swift b/apps/vibecheck-ios/VibeCheck/Views/VibeCheckView.swift new file mode 100644 index 00000000..9ae88128 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/VibeCheckView.swift @@ -0,0 +1,177 @@ +import SwiftUI +import SwiftData + +struct VibeCheckView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \MoodLog.timestamp, order: .reverse) private var moodLogs: [MoodLog] + @State private var healthManager = HealthKitManager() + + var body: some View { + NavigationStack { + List { + currentReadingsSection + activityLevelSection + moodHistorySection + privacySection + } + .navigationTitle("Vibe Check") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { + await healthManager.fetchCurrentContext() + } + } label: { + Image(systemName: "arrow.clockwise") + } + } + } + .task { + if healthManager.isHealthDataAvailable { + try? await healthManager.requestAuthorization() + await healthManager.fetchCurrentContext() + } + } + } + } + + private var currentReadingsSection: some View { + Section("Current Readings") { + ReadingRow( + icon: "heart.text.square", + iconColor: .red, + label: "Heart Rate Variability", + value: healthManager.currentHRV.map { String(format: "%.0f ms", $0) } ?? "—" + ) + + ReadingRow( + icon: "bed.double", + iconColor: .indigo, + label: "Last Night's Sleep", + value: healthManager.lastSleepHours.map { String(format: "%.1f hrs", $0) } ?? "—" + ) + + ReadingRow( + icon: "figure.walk", + iconColor: .green, + label: "Steps Today", + value: healthManager.stepsToday.map { String(format: "%.0f", $0) } ?? "—" + ) + + ReadingRow( + icon: "heart", + iconColor: .pink, + label: "Resting Heart Rate", + value: healthManager.restingHeartRate.map { String(format: "%.0f bpm", $0) } ?? "—" + ) + } + } + + private var activityLevelSection: some View { + Section("Activity Level") { + HStack { + ForEach(HealthKitManager.ActivityLevel.allCases.filter { $0 != .unknown }, id: \.self) { level in + VStack(spacing: 4) { + Circle() + .fill(healthManager.activityLevel == level ? .green : Color.gray.opacity(0.3)) + .frame(width: 12, height: 12) + Text(level.rawValue) + .font(.caption2) + .foregroundStyle(healthManager.activityLevel == level ? .primary : .secondary) + } + .frame(maxWidth: .infinity) + } + } + .padding(.vertical, 8) + } + } + + private var moodHistorySection: some View { + Section("Mood History") { + if moodLogs.isEmpty { + Text("No mood data yet") + .foregroundStyle(.secondary) + } else { + ForEach(moodLogs.prefix(10)) { log in + MoodLogRow(log: log) + } + } + } + } + + private var privacySection: some View { + Section { + HStack(spacing: 12) { + Image(systemName: "lock.shield.fill") + .font(.title2) + .foregroundStyle(.green) + + VStack(alignment: .leading, spacing: 4) { + Text("Your Data Stays Private") + .font(.headline) + Text("All health data is processed on-device and never uploaded to any server.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 8) + } + } +} + +struct ReadingRow: View { + let icon: String + let iconColor: Color + let label: String + let value: String + + var body: some View { + HStack { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(iconColor) + .frame(width: 32) + + Text(label) + .foregroundStyle(.secondary) + + Spacer() + + Text(value) + .font(.headline) + .monospacedDigit() + } + } +} + +struct MoodLogRow: View { + let log: MoodLog + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(log.recommendationHint.capitalized) + .font(.headline) + + HStack(spacing: 8) { + Text("Energy: \(log.energy.capitalized)") + Text("•") + Text("Stress: \(log.stress.capitalized)") + } + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(log.timestamp, style: .relative) + .font(.caption) + .foregroundStyle(.tertiary) + } + } +} + +#Preview { + VibeCheckView() + .modelContainer(for: [MoodLog.self], inMemory: true) +} diff --git a/apps/vibecheck-ios/VibeCheck/Views/WatchlistView.swift b/apps/vibecheck-ios/VibeCheck/Views/WatchlistView.swift new file mode 100644 index 00000000..6ca2a516 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Views/WatchlistView.swift @@ -0,0 +1,161 @@ +import SwiftUI +import SwiftData + +struct WatchlistView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \WatchlistItem.addedDate, order: .reverse) private var watchlistItems: [WatchlistItem] + @Query(sort: \WatchHistory.timestamp, order: .reverse) private var watchHistory: [WatchHistory] + + @State private var selectedTab = 0 + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // Segmented control + Picker("View", selection: $selectedTab) { + Text("Watchlist").tag(0) + Text("History").tag(1) + } + .pickerStyle(.segmented) + .padding() + + if selectedTab == 0 { + watchlistContent + } else { + historyContent + } + } + .navigationTitle("My List") + } + } + + @ViewBuilder + private var watchlistContent: some View { + if watchlistItems.isEmpty { + ContentUnavailableView( + "No Saved Items", + systemImage: "bookmark", + description: Text("Items you save will appear here") + ) + } else { + List { + ForEach(watchlistItems) { item in + WatchlistItemRow(item: item) + } + .onDelete(perform: deleteWatchlistItems) + } + } + } + + @ViewBuilder + private var historyContent: some View { + if watchHistory.isEmpty { + ContentUnavailableView( + "No Watch History", + systemImage: "clock", + description: Text("Your viewing history will appear here") + ) + } else { + List { + ForEach(watchHistory) { item in + WatchHistoryRow(item: item) + } + .onDelete(perform: deleteHistoryItems) + } + } + } + + private func deleteWatchlistItems(at offsets: IndexSet) { + for index in offsets { + modelContext.delete(watchlistItems[index]) + } + } + + private func deleteHistoryItems(at offsets: IndexSet) { + for index in offsets { + modelContext.delete(watchHistory[index]) + } + } +} + +struct WatchlistItemRow: View { + let item: WatchlistItem + + var body: some View { + HStack(spacing: 12) { + // Placeholder poster + RoundedRectangle(cornerRadius: 8) + .fill(.quaternary) + .frame(width: 50, height: 75) + .overlay { + Image(systemName: "film") + .foregroundStyle(.tertiary) + } + + VStack(alignment: .leading, spacing: 4) { + Text(item.mediaTitle) + .font(.headline) + + if let platform = item.platform { + Text(platform.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text("Added \(item.addedDate, style: .relative)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Spacer() + } + .padding(.vertical, 4) + } +} + +struct WatchHistoryRow: View { + let item: WatchHistory + + var body: some View { + HStack(spacing: 12) { + // Placeholder poster + RoundedRectangle(cornerRadius: 8) + .fill(.quaternary) + .frame(width: 50, height: 75) + .overlay { + Image(systemName: "film") + .foregroundStyle(.tertiary) + } + + VStack(alignment: .leading, spacing: 4) { + Text(item.mediaTitle) + .font(.headline) + + if item.completionPercent > 0 { + ProgressView(value: item.completionPercent) + .tint(.green) + } + + HStack { + if let moodHint = item.moodHint { + Label(moodHint.capitalized, systemImage: "waveform.path.ecg") + } + + Spacer() + + Text(item.timestamp, style: .relative) + } + .font(.caption2) + .foregroundStyle(.tertiary) + } + + Spacer() + } + .padding(.vertical, 4) + } +} + +#Preview { + WatchlistView() + .modelContainer(for: [WatchlistItem.self, WatchHistory.self], inMemory: true) +} diff --git a/apps/vibecheck-ios/VibeCheck/Widget/VibeWidget.swift b/apps/vibecheck-ios/VibeCheck/Widget/VibeWidget.swift new file mode 100644 index 00000000..9c6d1452 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheck/Widget/VibeWidget.swift @@ -0,0 +1,323 @@ +import WidgetKit +import SwiftUI + +// MARK: - Widget Entry + +struct VibeEntry: TimelineEntry { + let date: Date + let moodHint: String + let moodIcon: String + let moodDescription: String + let topRecommendation: String? + let platform: String? + let confidence: Double +} + +// MARK: - Timeline Provider + +struct VibeProvider: TimelineProvider { + func placeholder(in context: Context) -> VibeEntry { + VibeEntry( + date: Date(), + moodHint: "balanced", + moodIcon: "circle.grid.cross.fill", + moodDescription: "Balanced", + topRecommendation: "Abbott Elementary", + platform: "Hulu", + confidence: 0.5 + ) + } + + func getSnapshot(in context: Context, completion: @escaping (VibeEntry) -> Void) { + let entry = VibeEntry( + date: Date(), + moodHint: "chill", + moodIcon: "leaf.circle.fill", + moodDescription: "Chill", + topRecommendation: "Ted Lasso", + platform: "Apple TV+", + confidence: 0.8 + ) + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + // In a real implementation, this would read from shared UserDefaults/AppGroup + // For now, return a static timeline that updates hourly + let currentDate = Date() + + let entry = VibeEntry( + date: currentDate, + moodHint: "engaging", + moodIcon: "sparkles", + moodDescription: "Engaged", + topRecommendation: "Severance", + platform: "Apple TV+", + confidence: 0.7 + ) + + // Update every hour + let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! + let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) + completion(timeline) + } +} + +// MARK: - Widget Views + +struct VibeWidgetEntryView: View { + var entry: VibeProvider.Entry + @Environment(\.widgetFamily) var family + + var body: some View { + switch family { + case .systemSmall: + SmallWidgetView(entry: entry) + case .systemMedium: + MediumWidgetView(entry: entry) + case .accessoryRectangular: + RectangularWidgetView(entry: entry) + case .accessoryCircular: + CircularWidgetView(entry: entry) + default: + SmallWidgetView(entry: entry) + } + } +} + +struct SmallWidgetView: View { + let entry: VibeEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Mood indicator + HStack(spacing: 6) { + Image(systemName: entry.moodIcon) + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(moodColor) + + Text(entry.moodDescription) + .font(.headline) + .fontWeight(.semibold) + } + + Spacer() + + // Recommendation + if let title = entry.topRecommendation { + VStack(alignment: .leading, spacing: 2) { + Text("Watch") + .font(.caption2) + .foregroundStyle(.secondary) + + Text(title) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(2) + + if let platform = entry.platform { + Text(platform) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding() + } + + private var moodColor: Color { + switch entry.moodHint { + case "comfort": return .purple + case "gentle": return .blue + case "light": return .yellow + case "engaging": return .green + case "exciting": return .orange + case "calming": return .teal + default: return .gray + } + } +} + +struct MediumWidgetView: View { + let entry: VibeEntry + + var body: some View { + HStack(spacing: 16) { + // Left side - Mood + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: entry.moodIcon) + .font(.title) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(moodColor) + + VStack(alignment: .leading) { + Text("Your Vibe") + .font(.caption) + .foregroundStyle(.secondary) + Text(entry.moodDescription) + .font(.headline) + } + } + + Text("\(entry.moodHint.capitalized) mode") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(moodColor.opacity(0.2), in: Capsule()) + } + + Divider() + + // Right side - Recommendation + VStack(alignment: .leading, spacing: 4) { + Text("Recommended") + .font(.caption) + .foregroundStyle(.secondary) + + if let title = entry.topRecommendation { + Text(title) + .font(.headline) + .lineLimit(2) + + if let platform = entry.platform { + HStack(spacing: 4) { + Circle() + .fill(platformColor(platform)) + .frame(width: 8, height: 8) + Text(platform) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + } + + private var moodColor: Color { + switch entry.moodHint { + case "comfort": return .purple + case "gentle": return .blue + case "light": return .yellow + case "engaging": return .green + case "exciting": return .orange + case "calming": return .teal + default: return .gray + } + } + + private func platformColor(_ platform: String) -> Color { + switch platform.lowercased() { + case "netflix": return .red + case "hulu": return .green + case "apple tv+", "apple": return .gray + case "max", "hbo": return .purple + case "prime": return .cyan + case "disney+": return .blue + default: return .secondary + } + } +} + +struct RectangularWidgetView: View { + let entry: VibeEntry + + var body: some View { + HStack { + Image(systemName: entry.moodIcon) + .font(.title2) + + VStack(alignment: .leading) { + Text(entry.moodDescription) + .font(.headline) + + if let title = entry.topRecommendation { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + } + } +} + +struct CircularWidgetView: View { + let entry: VibeEntry + + var body: some View { + ZStack { + Circle() + .stroke(lineWidth: 3) + .opacity(0.2) + + Circle() + .trim(from: 0, to: entry.confidence) + .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + + Image(systemName: entry.moodIcon) + .font(.title3) + } + } +} + +// MARK: - Widget Configuration + +struct VibeWidget: Widget { + let kind: String = "VibeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: VibeProvider()) { entry in + VibeWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Your Vibe") + .description("See your current mood and a personalized recommendation.") + .supportedFamilies([ + .systemSmall, + .systemMedium, + .accessoryRectangular, + .accessoryCircular + ]) + } +} + +// MARK: - Previews + +#Preview(as: .systemSmall) { + VibeWidget() +} timeline: { + VibeEntry( + date: Date(), + moodHint: "chill", + moodIcon: "leaf.circle.fill", + moodDescription: "Chill", + topRecommendation: "Ted Lasso", + platform: "Apple TV+", + confidence: 0.8 + ) +} + +#Preview(as: .systemMedium) { + VibeWidget() +} timeline: { + VibeEntry( + date: Date(), + moodHint: "engaging", + moodIcon: "sparkles", + moodDescription: "Engaged", + topRecommendation: "Severance", + platform: "Apple TV+", + confidence: 0.7 + ) +} diff --git a/apps/vibecheck-ios/VibeCheckTests/ARWRunner.swift b/apps/vibecheck-ios/VibeCheckTests/ARWRunner.swift new file mode 100644 index 00000000..f9446223 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/ARWRunner.swift @@ -0,0 +1,28 @@ +import XCTest + +@main +class ARWRunner: NSObject { + static func main() { + // Create an observer that prints to stdout + let observer = XCTestObservationCenter.shared + observer.addTestObserver(TestPrinter()) + + let suite = XCTestSuite.default + // Add our test case + suite.addTest(XCTestSuite(forTestCaseClass: ARWServiceTests.self)) + + suite.run() + } +} + +class TestPrinter: NSObject, XCTestObservation { + func testCase(_ testCase: XCTestCase, didFailWithDescription description: String, inFile filePath: String?, atLine lineNumber: Int) { + print("❌ \(testCase.name) FAILED: \(description)") + } + + func testCaseDidFinish(_ testCase: XCTestCase) { + if testCase.testRun?.hasSucceeded == true { + print("✅ \(testCase.name) PASSED") + } + } +} diff --git a/apps/vibecheck-ios/VibeCheckTests/ARWServiceTests.swift b/apps/vibecheck-ios/VibeCheckTests/ARWServiceTests.swift new file mode 100644 index 00000000..b8289071 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/ARWServiceTests.swift @@ -0,0 +1,226 @@ +import Foundation + +// MARK: - Mock Helpers + +class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + fatalError("Received unexpected request with no handler set") + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let data = data { + client?.urlProtocol(self, didLoad: data) + } + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +// MARK: - Test Suite + +func assert(_ condition: Bool, _ message: String, file: String = #file, line: Int = #line) { + if !condition { + print("❌ FAILED: \(message) at \(file):\(line)") + exit(1) + } +} + +func assertEqual(_ actual: T, _ expected: T, _ message: String = "", file: String = #file, line: Int = #line) { + if actual != expected { + print("❌ FAILED: \(message) - Expected \(expected), got \(actual) at \(file):\(line)") + exit(1) + } +} + +@main +class ARWTestRunner { + static func main() async { + print("🏃 Running ARWService Tests...") + + // Setup + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let session = URLSession(configuration: config) + ARWService.shared.configure(session: session) + + do { + try await testFetchManifestSuccess() + try await testSearchSuccess() + print("✅ All ARW Tests Passed!") + } catch { + print("❌ Unexpected Error: \(error)") + exit(1) + } + } + + static func testSearch_MapsArtworkCorrectly() async throws { + // 1. Prepare Mock Data with Artwork + let mockJSON = """ + { + "success": true, + "results": [ + { + "content": { + "id": 550, + "title": "Fight Club", + "overview": "An insomniac office worker...", + "media_type": "movie", + "genre_ids": [18], + "vote_average": 8.4, + "poster_path": "/pB8BM7pdSp6B6Ih7Qf4n6a8MIxdf.jpg", + "backdrop_path": "/hZkgoQYus5vegHoetLkCJzb17zJ.jpg" + }, + "relevance_score": 0.9, + "match_reasons": ["intense"], + "explanation": "Gripping psychological drama." + } + ] + } + """.data(using: .utf8)! + + MockURLProtocol.requestHandler = { request in + // Return manifest for the first call if needed, or just specific responses based on URL + if request.url?.absoluteString.contains("search") == true { + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, mockJSON) + } else { + // Manifest fallback + let manifestJSON = """ + { + "version": "1.0", + "profile": "media-discovery", + "site": { "name": "Media", "description": "Search" }, + "actions": [ + { "id": "semantic_search", "endpoint": "/api/search", "method": "POST" } + ] + } + """.data(using: .utf8)! + return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, manifestJSON) + } + } + + // 2. Execute Search + let results = try await ARWService.shared.search(query: "test") + + // 3. Assertions + guard let item = results.first else { + assert(false, "Should return 1 item") + return + } + + assert(item.posterPath == "/pB8BM7pdSp6B6Ih7Qf4n6a8MIxdf.jpg", "Poster path mismatch") + + // Check computed URLs + let expectedPosterURL = URL(string: "https://image.tmdb.org/t/p/w500/pB8BM7pdSp6B6Ih7Qf4n6a8MIxdf.jpg") + let expectedBackdropURL = URL(string: "https://image.tmdb.org/t/p/w780/hZkgoQYus5vegHoetLkCJzb17zJ.jpg") + + assert(item.posterURL == expectedPosterURL, "Computed Poster URL mismatch") + assert(item.backdropURL == expectedBackdropURL, "Computed Backdrop URL mismatch") + + print("✅ testSearch_MapsArtworkCorrectly Passed") + } + + static func testFetchManifestSuccess() async throws { + print(" testFetchManifestSuccess...") + let json = """ + { + "version": "0.1", + "profile": "ARW-1", + "site": { + "name": "Test Site", + "description": "Test Description" + }, + "actions": [ + { + "id": "semantic_search", + "endpoint": "/api/search", + "method": "POST" + } + ] + } + """ + let data = json.data(using: .utf8)! + + MockURLProtocol.requestHandler = { request in + guard let url = request.url, url.absoluteString.hasSuffix("/.well-known/arw-manifest.json") else { + throw URLError(.badURL) + } + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, data) + } + + let manifest = try await ARWService.shared.fetchManifest() + assertEqual(manifest.site.name, "Test Site") + assertEqual(manifest.actions.first?.id, "semantic_search") + } + + static func testSearchSuccess() async throws { + print(" testSearchSuccess...") + + // 1. Mock Manifest + let manifestJson = """ + { + "version": "0.1", + "profile": "ARW-1", + "site": { "name": "Test", "description": "Test" }, + "actions": [ { "id": "semantic_search", "endpoint": "/api/search", "method": "POST" } ] + } + """ + + // 2. Mock Search Response + let searchJson = """ + { + "success": true, + "results": [ + { + "content": { + "id": 123, + "title": "Test Movie", + "overview": "A test movie overview", + "voteAverage": 8.5, + "mediaType": "movie", + "genreIds": [28] + }, + "relevanceScore": 0.9, + "matchReasons": ["intense"], + "explanation": "Because you like action." + } + ] + } + """ + + MockURLProtocol.requestHandler = { request in + if request.url?.absoluteString.hasSuffix("arw-manifest.json") == true { + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, manifestJson.data(using: .utf8)!) + } else if request.url?.absoluteString.hasSuffix("/api/search") == true { + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, searchJson.data(using: .utf8)!) + } + throw URLError(.badURL) + } + + let results = try await ARWService.shared.search(query: "Test Query") + + assertEqual(results.count, 1) + assertEqual(results.first?.title, "Test Movie") + assertEqual(results.first?.sommelierRationale, "Because you like action.") + } +} + diff --git a/apps/vibecheck-ios/VibeCheckTests/CloudKitManagerTests.swift b/apps/vibecheck-ios/VibeCheckTests/CloudKitManagerTests.swift new file mode 100644 index 00000000..791ae7ec --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/CloudKitManagerTests.swift @@ -0,0 +1,71 @@ +import XCTest +import CloudKit +@testable import VibeCheck + +class CloudKitManagerTests: XCTestCase { + + var mockContainer: MockCKContainer! + var mockDatabase: MockCKDatabase! + var manager: CloudKitManager! + + override func setUp() { + super.setUp() + mockContainer = MockCKContainer() + mockDatabase = mockContainer.mockPrivateDB + // Dependency Injection needed for CloudKitManager + manager = CloudKitManager(container: mockContainer) + } + + func testToCKRecord() { + // Arrange + let timestamp = Date() + let familyVibe = FamilyMemberVibe( + id: UUID(), + userID: "user_123", + vibeKeyword: "Chill", + vibeEnergy: "low", + vibeStress: "relaxed", + colorHex: "#00FF00", + lastUpdated: timestamp + ) + + // Act + let record = familyVibe.toCKRecord() + + // Assert + XCTAssertEqual(record.recordType, "FamilyVibe") + XCTAssertEqual(record["vibe_keyword"] as? String, "Chill") + XCTAssertEqual(record["user_id"] as? String, "user_123") + XCTAssertEqual(record["color_hex"] as? String, "#00FF00") + } + + func testPublishVibe() { + // Arrange + let expectation = self.expectation(description: "Publish Vibe") + let familyVibe = FamilyMemberVibe( + id: UUID(), + userID: "me", + vibeKeyword: "Stressed", + vibeEnergy: "high", + vibeStress: "high", + colorHex: "#FF0000", + lastUpdated: Date() + ) + + // Act + manager.publishVibe(familyVibe) { result in + switch result { + case .success: + expectation.fulfill() + case .failure(let error): + XCTFail("Failed with error: \(error)") + } + } + + waitForExpectations(timeout: 1.0, handler: nil) + + // Assert + XCTAssertEqual(mockDatabase.savedRecords.count, 1) + XCTAssertEqual(mockDatabase.savedRecords.first?["vibe_keyword"] as? String, "Stressed") + } +} diff --git a/apps/vibecheck-ios/VibeCheckTests/CloudKitMocks.swift b/apps/vibecheck-ios/VibeCheckTests/CloudKitMocks.swift new file mode 100644 index 00000000..d12eb855 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/CloudKitMocks.swift @@ -0,0 +1,41 @@ +import Foundation +import CloudKit +@testable import VibeCheck + +// MARK: - Mocks + +class MockCKDatabase: CKDatabaseProtocol { + var savedRecords: [CKRecord] = [] + var recordsToReturn: [CKRecord] = [] + var errorToReturn: Error? + + func save(_ record: CKRecord, completionHandler: @escaping (CKRecord?, Error?) -> Void) { + if let error = errorToReturn { + completionHandler(nil, error) + return + } + savedRecords.append(record) + // Simulate async + DispatchQueue.global().async { + completionHandler(record, nil) + } + } + + func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZone.ID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void) { + if let error = errorToReturn { + completionHandler(nil, error) + return + } + DispatchQueue.global().async { + completionHandler(self.recordsToReturn, nil) + } + } +} + +class MockCKContainer: CKContainerProtocol { + let mockPrivateDB = MockCKDatabase() + + var privateCloudDatabaseProtocol: CKDatabaseProtocol { + return mockPrivateDB + } +} diff --git a/apps/vibecheck-ios/VibeCheckTests/HealthKitManagerTests.swift b/apps/vibecheck-ios/VibeCheckTests/HealthKitManagerTests.swift new file mode 100644 index 00000000..3002acd4 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/HealthKitManagerTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import VibeCheck + +final class HealthKitManagerTests: XCTestCase { + + var manager: HealthKitManager! + + override func setUp() { + super.setUp() + manager = HealthKitManager() + } + + override func tearDown() { + manager = nil + super.tearDown() + } + + func testInitializationDefaults() { + XCTAssertNil(manager.currentHRV) + XCTAssertNil(manager.lastSleepHours) + XCTAssertNil(manager.restingHeartRate) + XCTAssertNil(manager.stepsToday) + XCTAssertEqual(manager.activityLevel, .unknown) + XCTAssertFalse(manager.isLoading) + XCTAssertFalse(manager.isAuthorized) + XCTAssertNil(manager.errorMessage) + } + + func testActivityClassification() { + // Sedentary < 2000 + XCTAssertEqual(manager.classifyActivity(steps: 0), .sedentary) + XCTAssertEqual(manager.classifyActivity(steps: 1999), .sedentary) + + // Light 2000..<5000 + XCTAssertEqual(manager.classifyActivity(steps: 2000), .light) + XCTAssertEqual(manager.classifyActivity(steps: 4999), .light) + + // Moderate 5000..<10000 + XCTAssertEqual(manager.classifyActivity(steps: 5000), .moderate) + XCTAssertEqual(manager.classifyActivity(steps: 9999), .moderate) + + // Active >= 10000 + XCTAssertEqual(manager.classifyActivity(steps: 10000), .active) + XCTAssertEqual(manager.classifyActivity(steps: 15000), .active) + } +} diff --git a/apps/vibecheck-ios/VibeCheckTests/InteractionServiceTests.swift b/apps/vibecheck-ios/VibeCheckTests/InteractionServiceTests.swift new file mode 100644 index 00000000..5848451b --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/InteractionServiceTests.swift @@ -0,0 +1,368 @@ +// +// InteractionServiceTests.swift +// VibeCheckTests +// +// TDD tests for InteractionService - WASM learning integration +// SPARC Phase 4: RED - Tests written before implementation +// + +import XCTest +import SwiftData +@testable import VibeCheck + +@available(iOS 17.0, *) +class InteractionServiceTests: XCTestCase { + + var modelContainer: ModelContainer! + var modelContext: ModelContext! + var service: InteractionService! + + override func setUp() async throws { + try await super.setUp() + + // In-memory SwiftData container + let schema = Schema([MediaInteraction.self, WatchHistory.self, WatchlistItem.self]) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + modelContainer = try ModelContainer(for: schema, configurations: [config]) + modelContext = ModelContext(modelContainer) + + // Create service (will use mock bridge in tests) + service = InteractionService(modelContext: modelContext) + } + + override func tearDown() async throws { + service = nil + modelContainer = nil + modelContext = nil + try await super.tearDown() + } + + // MARK: - Rating Operations + + func testRateMediaThumbsUp() async throws { + // Given: A media item and mood + let mediaItem = MediaItem.samples.first! + let mood = MoodState(energy: .moderate, stress: .neutral) + + // When: Rating thumbs up + let interaction = try await service.rate( + mediaItem: mediaItem, + rating: .thumbsUp, + mood: mood + ) + + // Then: Interaction should be created with rating + XCTAssertEqual(interaction.mediaId, mediaItem.id) + XCTAssertEqual(interaction.rating, .thumbsUp) + } + + func testRateMediaThumbsDown() async throws { + // Given: A media item and mood + let mediaItem = MediaItem.samples.first! + let mood = MoodState(energy: .low, stress: .stressed) + + // When: Rating thumbs down + let interaction = try await service.rate( + mediaItem: mediaItem, + rating: .thumbsDown, + mood: mood + ) + + // Then: Interaction should have thumbs down + XCTAssertEqual(interaction.rating, .thumbsDown) + } + + func testToggleRatingClearsIfSame() async throws { + // Given: Existing thumbs up interaction + let mediaItem = MediaItem.samples.first! + let mood = MoodState.chill + _ = try await service.rate(mediaItem: mediaItem, rating: .thumbsUp, mood: mood) + + // When: Rating thumbs up again (toggle) + let interaction = try await service.toggleRating( + mediaItem: mediaItem, + rating: .thumbsUp, + mood: mood + ) + + // Then: Rating should be cleared + XCTAssertNil(interaction.rating) + } + + func testToggleRatingSwitchesRating() async throws { + // Given: Existing thumbs up interaction + let mediaItem = MediaItem.samples.first! + let mood = MoodState.chill + _ = try await service.rate(mediaItem: mediaItem, rating: .thumbsUp, mood: mood) + + // When: Toggling to thumbs down + let interaction = try await service.toggleRating( + mediaItem: mediaItem, + rating: .thumbsDown, + mood: mood + ) + + // Then: Rating should switch + XCTAssertEqual(interaction.rating, .thumbsDown) + } + + // MARK: - Seen Status Operations + + func testMarkAsSeen() async throws { + // Given: A media item + let mediaItem = MediaItem.samples.first! + let mood = MoodState.chill + + // When: Marking as seen + let interaction = try await service.markAsSeen( + mediaItem: mediaItem, + mood: mood + ) + + // Then: Should be marked as seen + XCTAssertTrue(interaction.hasSeen) + XCTAssertNotNil(interaction.seenAt) + } + + func testMarkAsUnseen() async throws { + // Given: Seen interaction + let mediaItem = MediaItem.samples.first! + let mood = MoodState.chill + _ = try await service.markAsSeen(mediaItem: mediaItem, mood: mood) + + // When: Marking as unseen + let interaction = try await service.markAsUnseen(mediaItem: mediaItem) + + // Then: Should be unseen + XCTAssertFalse(interaction.hasSeen) + } + + func testToggleSeen() async throws { + // Given: Unseen media + let mediaItem = MediaItem.samples.first! + let mood = MoodState.chill + + // When: Toggling seen + let interaction1 = try await service.toggleSeen(mediaItem: mediaItem, mood: mood) + XCTAssertTrue(interaction1.hasSeen) + + // When: Toggling again + let interaction2 = try await service.toggleSeen(mediaItem: mediaItem, mood: mood) + XCTAssertFalse(interaction2.hasSeen) + } + + // MARK: - WASM Learning Integration + + func testRatingTriggersLearning() async throws { + // Given: Service with learning enabled + let mediaItem = MediaItem.samples.first! + let mood = MoodState(energy: .high, stress: .relaxed) + + // When: Rating thumbs up + _ = try await service.rate( + mediaItem: mediaItem, + rating: .thumbsUp, + mood: mood + ) + + // Then: Learning should be triggered (verified via mock or stats) + // Note: In real implementation, this verifies LearningMemoryService was called + let stats = await service.getLearningStats() + XCTAssertGreaterThanOrEqual(stats.totalInteractions, 1) + } + + func testSeenTriggersLearning() async throws { + // Given: Service with learning enabled + let mediaItem = MediaItem.samples.first! + let mood = MoodState.chill + + // When: Marking as seen + _ = try await service.markAsSeen(mediaItem: mediaItem, mood: mood) + + // Then: Learning should be triggered with .watched feedback + let stats = await service.getLearningStats() + XCTAssertGreaterThanOrEqual(stats.seenCount, 1) + } + + // MARK: - Fetch Operations + + func testGetInteraction() async throws { + // Given: Existing interaction + let mediaItem = MediaItem.samples.first! + _ = try await service.rate( + mediaItem: mediaItem, + rating: .thumbsUp, + mood: MoodState.chill + ) + + // When: Getting interaction + let interaction = try await service.getInteraction(for: mediaItem.id) + + // Then: Should return the interaction + XCTAssertNotNil(interaction) + XCTAssertEqual(interaction?.rating, .thumbsUp) + } + + func testGetInteractionReturnsNilIfNotFound() async throws { + // When: Getting non-existent interaction + let interaction = try await service.getInteraction(for: "non-existent-id") + + // Then: Should return nil + XCTAssertNil(interaction) + } + + func testGetAllLikedMedia() async throws { + // Given: Multiple interactions + let items = Array(MediaItem.samples.prefix(3)) + let mood = MoodState.chill + + _ = try await service.rate(mediaItem: items[0], rating: .thumbsUp, mood: mood) + _ = try await service.rate(mediaItem: items[1], rating: .thumbsDown, mood: mood) + _ = try await service.rate(mediaItem: items[2], rating: .thumbsUp, mood: mood) + + // When: Getting liked media + let liked = try await service.getAllLiked() + + // Then: Should return only thumbs up items + XCTAssertEqual(liked.count, 2) + XCTAssertTrue(liked.allSatisfy { $0.rating == .thumbsUp }) + } + + func testGetAllSeenMedia() async throws { + // Given: Multiple interactions + let items = Array(MediaItem.samples.prefix(3)) + let mood = MoodState.chill + + _ = try await service.markAsSeen(mediaItem: items[0], mood: mood) + _ = try await service.markAsSeen(mediaItem: items[1], mood: mood) + // items[2] not marked as seen + + // When: Getting seen media + let seen = try await service.getAllSeen() + + // Then: Should return only seen items + XCTAssertEqual(seen.count, 2) + XCTAssertTrue(seen.allSatisfy { $0.hasSeen }) + } + + // MARK: - Batch Operations + + func testGetInteractionsForMultipleMedia() async throws { + // Given: Multiple interactions + let items = Array(MediaItem.samples.prefix(3)) + let mood = MoodState.chill + + for item in items { + _ = try await service.rate(mediaItem: item, rating: .thumbsUp, mood: mood) + } + + // When: Getting interactions for specific IDs + let mediaIds = items.map { $0.id } + let interactions = try await service.getInteractions(for: mediaIds) + + // Then: Should return all matching interactions + XCTAssertEqual(interactions.count, 3) + } + + // MARK: - Statistics + + func testLearningStats() async throws { + // Given: Various interactions + let items = Array(MediaItem.samples.prefix(4)) + let mood = MoodState.chill + + _ = try await service.rate(mediaItem: items[0], rating: .thumbsUp, mood: mood) + _ = try await service.rate(mediaItem: items[1], rating: .thumbsDown, mood: mood) + _ = try await service.markAsSeen(mediaItem: items[2], mood: mood) + _ = try await service.markAsSeen(mediaItem: items[3], mood: mood) + + // When: Getting stats + let stats = await service.getLearningStats() + + // Then: Stats should reflect interactions + XCTAssertEqual(stats.thumbsUpCount, 1) + XCTAssertEqual(stats.thumbsDownCount, 1) + XCTAssertEqual(stats.seenCount, 2) + XCTAssertEqual(stats.totalInteractions, 4) + } +} + +// MARK: - Integration with Existing Systems + +@available(iOS 17.0, *) +class InteractionWatchlistIntegrationTests: XCTestCase { + + var modelContainer: ModelContainer! + var modelContext: ModelContext! + var service: InteractionService! + + override func setUp() async throws { + try await super.setUp() + let schema = Schema([MediaInteraction.self, WatchHistory.self, WatchlistItem.self]) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + modelContainer = try ModelContainer(for: schema, configurations: [config]) + modelContext = ModelContext(modelContainer) + service = InteractionService(modelContext: modelContext) + } + + override func tearDown() async throws { + service = nil + modelContainer = nil + modelContext = nil + try await super.tearDown() + } + + func testSeenCreatesWatchHistory() async throws { + // Given: A media item + let mediaItem = MediaItem.samples.first! + let mood = MoodState.chill + + // When: Marking as seen + _ = try await service.markAsSeen(mediaItem: mediaItem, mood: mood) + + // Then: WatchHistory entry should be created + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == mediaItem.id } + ) + let history = try modelContext.fetch(descriptor) + + XCTAssertEqual(history.count, 1) + XCTAssertEqual(history.first?.mediaTitle, mediaItem.title) + } + + func testUnseenRemovesFromWatchHistory() async throws { + // Given: Seen media with history + let mediaItem = MediaItem.samples.first! + let mood = MoodState.chill + _ = try await service.markAsSeen(mediaItem: mediaItem, mood: mood) + + // When: Marking as unseen + _ = try await service.markAsUnseen(mediaItem: mediaItem) + + // Then: WatchHistory entry should be removed + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == mediaItem.id } + ) + let history = try modelContext.fetch(descriptor) + + XCTAssertEqual(history.count, 0) + } + + func testInteractionDoesNotDuplicateWatchHistory() async throws { + // Given: Already seen media + let mediaItem = MediaItem.samples.first! + let mood = MoodState.chill + _ = try await service.markAsSeen(mediaItem: mediaItem, mood: mood) + + // When: Marking as seen again + _ = try await service.markAsSeen(mediaItem: mediaItem, mood: mood) + + // Then: Should not duplicate history + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == mediaItem.id } + ) + let history = try modelContext.fetch(descriptor) + + XCTAssertEqual(history.count, 1) + } +} diff --git a/apps/vibecheck-ios/VibeCheckTests/MediaInteractionTests.swift b/apps/vibecheck-ios/VibeCheckTests/MediaInteractionTests.swift new file mode 100644 index 00000000..b2d3c026 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/MediaInteractionTests.swift @@ -0,0 +1,310 @@ +// +// MediaInteractionTests.swift +// VibeCheckTests +// +// TDD tests for MediaInteraction model and service +// SPARC Phase 2: RED - Tests written before implementation +// + +import XCTest +import SwiftData +@testable import VibeCheck + +// MARK: - MediaInteraction Model Tests + +@available(iOS 17.0, *) +class MediaInteractionModelTests: XCTestCase { + + var modelContainer: ModelContainer! + var modelContext: ModelContext! + + override func setUp() async throws { + try await super.setUp() + + // In-memory SwiftData container for testing + let schema = Schema([MediaInteraction.self]) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + modelContainer = try ModelContainer(for: schema, configurations: [config]) + modelContext = ModelContext(modelContainer) + } + + override func tearDown() async throws { + modelContainer = nil + modelContext = nil + try await super.tearDown() + } + + // MARK: - Rating Tests + + func testCreateInteractionWithThumbsUp() throws { + // Given: A media item ID + let mediaId = "movie-123" + + // When: Creating interaction with thumbs up + let interaction = MediaInteraction( + mediaId: mediaId, + mediaTitle: "Test Movie", + rating: .thumbsUp + ) + + // Then: Should have correct rating + XCTAssertEqual(interaction.mediaId, mediaId) + XCTAssertEqual(interaction.rating, .thumbsUp) + XCTAssertFalse(interaction.hasSeen) + } + + func testCreateInteractionWithThumbsDown() throws { + // Given/When: Creating interaction with thumbs down + let interaction = MediaInteraction( + mediaId: "movie-456", + mediaTitle: "Bad Movie", + rating: .thumbsDown + ) + + // Then: Should have thumbs down rating + XCTAssertEqual(interaction.rating, .thumbsDown) + } + + func testInteractionDefaultsToNoRating() throws { + // When: Creating interaction without explicit rating + let interaction = MediaInteraction( + mediaId: "movie-789", + mediaTitle: "Neutral Movie" + ) + + // Then: Rating should be nil + XCTAssertNil(interaction.rating) + } + + func testToggleRating() throws { + // Given: Interaction with no rating + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie" + ) + + // When: Setting thumbs up + interaction.rating = .thumbsUp + XCTAssertEqual(interaction.rating, .thumbsUp) + + // When: Toggling same rating (should clear) + interaction.toggleRating(.thumbsUp) + XCTAssertNil(interaction.rating) + + // When: Setting thumbs down + interaction.toggleRating(.thumbsDown) + XCTAssertEqual(interaction.rating, .thumbsDown) + + // When: Switching to thumbs up + interaction.toggleRating(.thumbsUp) + XCTAssertEqual(interaction.rating, .thumbsUp) + } + + // MARK: - Seen Status Tests (alternate name: "Watched", "Viewed", "Consumed") + + func testMarkAsSeen() throws { + // Given: Unseen interaction + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie" + ) + XCTAssertFalse(interaction.hasSeen) + + // When: Marking as seen + interaction.markAsSeen() + + // Then: Should be marked as seen with timestamp + XCTAssertTrue(interaction.hasSeen) + XCTAssertNotNil(interaction.seenAt) + } + + func testMarkAsUnseen() throws { + // Given: Seen interaction + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie" + ) + interaction.markAsSeen() + XCTAssertTrue(interaction.hasSeen) + + // When: Marking as unseen + interaction.markAsUnseen() + + // Then: Should be unseen + XCTAssertFalse(interaction.hasSeen) + XCTAssertNil(interaction.seenAt) + } + + func testToggleSeen() throws { + // Given: Unseen interaction + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie" + ) + + // When: Toggling seen + interaction.toggleSeen() + XCTAssertTrue(interaction.hasSeen) + + // When: Toggling again + interaction.toggleSeen() + XCTAssertFalse(interaction.hasSeen) + } + + // MARK: - Persistence Tests + + func testPersistInteraction() throws { + // Given: An interaction + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie", + rating: .thumbsUp + ) + interaction.markAsSeen() + + // When: Saving to context + modelContext.insert(interaction) + try modelContext.save() + + // Then: Should be retrievable + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == "movie-123" } + ) + let fetched = try modelContext.fetch(descriptor) + + XCTAssertEqual(fetched.count, 1) + XCTAssertEqual(fetched.first?.rating, .thumbsUp) + XCTAssertTrue(fetched.first?.hasSeen ?? false) + } + + func testUpdateExistingInteraction() throws { + // Given: Persisted interaction + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie" + ) + modelContext.insert(interaction) + try modelContext.save() + + // When: Updating rating + interaction.rating = .thumbsDown + try modelContext.save() + + // Then: Change should persist + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.mediaId == "movie-123" } + ) + let fetched = try modelContext.fetch(descriptor) + + XCTAssertEqual(fetched.first?.rating, .thumbsDown) + } + + func testFindOrCreateInteraction() throws { + // Given: No existing interaction + let mediaId = "movie-new" + + // When: Finding or creating + let interaction = try MediaInteraction.findOrCreate( + mediaId: mediaId, + mediaTitle: "New Movie", + in: modelContext + ) + + // Then: Should create new + XCTAssertEqual(interaction.mediaId, mediaId) + + // When: Finding again + let found = try MediaInteraction.findOrCreate( + mediaId: mediaId, + mediaTitle: "New Movie", + in: modelContext + ) + + // Then: Should return same instance + XCTAssertEqual(interaction.id, found.id) + } + + // MARK: - Learning Score Tests + + func testLearningScoreForThumbsUp() throws { + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie", + rating: .thumbsUp + ) + + // Thumbs up should have positive learning score + XCTAssertEqual(interaction.learningScore, 1.0, accuracy: 0.01) + } + + func testLearningScoreForThumbsDown() throws { + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie", + rating: .thumbsDown + ) + + // Thumbs down should have negative learning score + XCTAssertEqual(interaction.learningScore, -1.0, accuracy: 0.01) + } + + func testLearningScoreForSeenOnly() throws { + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie" + ) + interaction.markAsSeen() + + // Seen without rating should have moderate positive score + XCTAssertEqual(interaction.learningScore, 0.5, accuracy: 0.01) + } + + func testLearningScoreForNoInteraction() throws { + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie" + ) + + // No interaction should have zero score + XCTAssertEqual(interaction.learningScore, 0.0, accuracy: 0.01) + } + + // MARK: - Mood Context Tests + + func testInteractionStoresMoodContext() throws { + // Given: Interaction with mood context + let interaction = MediaInteraction( + mediaId: "movie-123", + mediaTitle: "Test Movie", + rating: .thumbsUp, + moodHint: "comfort" + ) + + // Then: Mood hint should be stored + XCTAssertEqual(interaction.moodHint, "comfort") + } +} + +// MARK: - Rating Enum Tests + +@available(iOS 17.0, *) +class RatingTypeTests: XCTestCase { + + func testRatingRawValues() { + XCTAssertEqual(Rating.thumbsUp.rawValue, "thumbsUp") + XCTAssertEqual(Rating.thumbsDown.rawValue, "thumbsDown") + } + + func testRatingFeedbackType() { + // Thumbs up maps to .liked + XCTAssertEqual(Rating.thumbsUp.feedbackType, FeedbackType.liked) + + // Thumbs down maps to .disliked + XCTAssertEqual(Rating.thumbsDown.feedbackType, FeedbackType.disliked) + } + + func testRatingIcon() { + XCTAssertEqual(Rating.thumbsUp.iconName, "hand.thumbsup.fill") + XCTAssertEqual(Rating.thumbsDown.iconName, "hand.thumbsdown.fill") + } +} diff --git a/apps/vibecheck-ios/VibeCheckTests/MockModels.swift b/apps/vibecheck-ios/VibeCheckTests/MockModels.swift new file mode 100644 index 00000000..54543b58 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/MockModels.swift @@ -0,0 +1,13 @@ +import Foundation + +// Mock UserPreferences matching the API used by RecommendationEngine +class UserPreferences { + var favoriteGenres: [String] = [] + var avoidGenres: [String] = [] + var avoidTitles: [String] = [] + var preferredMinRuntime: Int? = nil + var preferredMaxRuntime: Int? = nil + var subscriptions: [String] = ["netflix", "hulu", "apple", "max", "prime"] // Default all + + init() {} +} diff --git a/apps/vibecheck-ios/VibeCheckTests/RuvectorBridgeTests.swift b/apps/vibecheck-ios/VibeCheckTests/RuvectorBridgeTests.swift new file mode 100644 index 00000000..4cf3b573 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/RuvectorBridgeTests.swift @@ -0,0 +1,307 @@ +// +// RuvectorBridgeTests.swift +// VibeCheckTests +// +// Tests for Ruvector WASM integration +// + +import XCTest +@testable import VibeCheck + +class RuvectorBridgeTests: XCTestCase { + + var bridge: RuvectorBridge! + let wasmPath = "/tmp/ruvector/examples/wasm/ios/target/wasm32-wasi/release/ruvector_ios_wasm.was" + + override func setUp() async throws { + try await super.setUp() + bridge = RuvectorBridge() + } + + override func tearDown() async throws { + bridge = nil + try await super.tearDown() + } + + // MARK: - Lifecycle Tests + + func testWASMLoads() async throws { + // Given: Fresh bridge instance + XCTAssertFalse(bridge.isReady) + + // When: Loading WASM module + try await bridge.load(wasmPath: wasmPath) + + // Then: Bridge should be ready + XCTAssertTrue(bridge.isReady) + } + + func testLoadingInvalidPathThrows() async throws { + // When/Then: Loading invalid path should throw + await assertThrowsError { + try await bridge.load(wasmPath: "/invalid/path.wasm") + } + } + + // MARK: - Context Mapping Tests + + func testVibeContextMapsToRuvectorState() async throws { + // Given: VibeCheck context + let context = VibeContext( + mood: MoodState(energy: .high, stress: .low), + biometrics: Biometrics( + hrv: .init(value: 50, date: Date()), + sleep: .init(hours: 7.5, quality: 0.8), + activity: .init(steps: 8000, activeMinutes: 45) + ), + keywords: ["action", "intense"] + ) + + // When: Mapping to Ruvector VibeState + let vibeState = bridge.mapToVibeState(context) + + // Then: Should map correctly + XCTAssertEqual(vibeState.energy, 0.8, accuracy: 0.1) // high energy + XCTAssertGreaterThan(vibeState.mood, 0.3) // low stress = positive mood + XCTAssertEqual(vibeState.focus, 0.7, accuracy: 0.2) // moderate focus + } + + // MARK: - Learning Tests + + func testRecordWatchEvent() async throws { + // Given: Loaded bridge + try await bridge.load(wasmPath: wasmPath) + + let item = MediaItem( + id: "test-1", + title: "Test Action Movie", + overview: "An intense action thriller", + genres: [Genre(name: "Action"), Genre(name: "Thriller")], + tone: ["intense", "fast-paced"], + intensity: 0.8, + runtime: 120, + year: 2024, + platforms: [] + ) + + let context = VibeContext( + mood: MoodState(energy: .high, stress: .low), + biometrics: mockBiometrics, + keywords: ["action"] + ) + + // When: Recording watch event + try await bridge.recordWatchEvent( + item, + context: context, + durationSeconds: 3600 // Watched full movie + ) + + // Then: Should not throw (successful recording) + // Internal state should be updated (verified in integration tests) + } + + func testLearnFromInteraction() async throws { + // Given: Loaded bridge with watch history + try await bridge.load(wasmPath: wasmPath) + try await bridge.recordWatchEvent(mockMediaItem, context: mockContext, durationSeconds: 1800) + + // When: User enjoyed the content (high satisfaction) + try await bridge.learn(satisfaction: 0.9) + + // Then: Learning should reinforce this pattern + // (Verified by checking recommendations improve) + } + + // MARK: - Recommendation Tests + + func testGetRecommendations() async throws { + // Given: Loaded bridge + try await bridge.load(wasmPath: wasmPath) + + let context = VibeContext( + mood: MoodState(energy: .balanced, stress: .low), + biometrics: mockBiometrics, + keywords: ["comedy"] + ) + + // When: Requesting recommendations + let recommendations = try await bridge.getRecommendations( + for: context, + limit: 5 + ) + + // Then: Should return items + XCTAssertGreaterThan(recommendations.count, 0) + XCTAssertLessThanOrEqual(recommendations.count, 5) + } + + func testRecommendationsImproveWithLearning() async throws { + // Given: Fresh bridge + try await bridge.load(wasmPath: wasmPath) + + let context = VibeContext( + mood: MoodState(energy: .high, stress: .low), + biometrics: mockBiometrics, + keywords: [] + ) + + // When: Initial recommendations + let initialRecs = try await bridge.getRecommendations(for: context, limit: 10) + + // User watches several action movies with high satisfaction + let actionMovies = mockActionMovies() + for movie in actionMovies { + try await bridge.recordWatchEvent(movie, context: context, durationSeconds: movie.runtime * 60) + try await bridge.learn(satisfaction: 0.9) + } + + // New recommendations + let updatedRecs = try await bridge.getRecommendations(for: context, limit: 10) + + // Then: Should have more action movies + let initialActionCount = initialRecs.filter { $0.hasGenre("Action") }.count + let updatedActionCount = updatedRecs.filter { $0.hasGenre("Action") }.count + + XCTAssertGreaterThan(updatedActionCount, initialActionCount, + "Learning should increase action movie recommendations") + } + + // MARK: - Persistence Tests + + func testStatePersistence() async throws { + // Given: Bridge with learned state + try await bridge.load(wasmPath: wasmPath) + + for movie in mockActionMovies() { + try await bridge.recordWatchEvent(movie, context: mockContext, durationSeconds: 1800) + } + + // When: Saving state + let stateData = try await bridge.saveState() + + // Then: State data should exist + XCTAssertGreaterThan(stateData.count, 0) + + // When: Creating new bridge and loading state + let newBridge = RuvectorBridge() + try await newBridge.load(wasmPath: wasmPath) + try await newBridge.loadState(stateData) + + // Then: Recommendations should be similar + let originalRecs = try await bridge.getRecommendations(for: mockContext, limit: 5) + let restoredRecs = try await newBridge.getRecommendations(for: mockContext, limit: 5) + + XCTAssertEqual(originalRecs.map { $0.id }, restoredRecs.map { $0.id }, + "Restored bridge should produce same recommendations") + } + + // MARK: - Performance Tests + + func testRecommendationPerformance() async throws { + // Given: Loaded bridge + try await bridge.load(wasmPath: wasmPath) + + // When: Measuring recommendation time + let start = Date() + _ = try await bridge.getRecommendations(for: mockContext, limit: 20) + let duration = Date().timeIntervalSince(start) + + // Then: Should be fast (< 50ms as per spec) + XCTAssertLessThan(duration, 0.050, "Recommendations should return in < 50ms") + } + + func testMemoryUsage() async throws { + // Given: Loaded bridge + try await bridge.load(wasmPath: wasmPath) + + // When: Recording many events + for i in 0..<100 { + let item = mockMediaItem(id: "item-\(i)") + try await bridge.recordWatchEvent(item, context: mockContext, durationSeconds: 60) + } + + // Then: Memory usage should be reasonable + // (Verified by Instruments profiling - manual test) + // Expected: < 15 MB per spec + } + + // MARK: - Helper Methods + + private var mockBiometrics: Biometrics { + Biometrics( + hrv: .init(value: 45, date: Date()), + sleep: .init(hours: 7.0, quality: 0.7), + activity: .init(steps: 5000, activeMinutes: 30) + ) + } + + private var mockContext: VibeContext { + VibeContext( + mood: MoodState(energy: .balanced, stress: .low), + biometrics: mockBiometrics, + keywords: [] + ) + } + + private var mockMediaItem: MediaItem { + MediaItem( + id: "mock-1", + title: "Mock Movie", + overview: "Test overview", + genres: [Genre(name: "Drama")], + tone: ["thoughtful"], + intensity: 0.5, + runtime: 120, + year: 2024, + platforms: [] + ) + } + + private func mockMediaItem(id: String) -> MediaItem { + MediaItem( + id: id, + title: "Movie \(id)", + overview: "Test", + genres: [Genre(name: "Drama")], + tone: [], + intensity: 0.5, + runtime: 90, + year: 2024, + platforms: [] + ) + } + + private func mockActionMovies() -> [MediaItem] { + return (1...5).map { i in + MediaItem( + id: "action-\(i)", + title: "Action Movie \(i)", + overview: "Intense action", + genres: [Genre(name: "Action")], + tone: ["intense", "fast-paced"], + intensity: 0.9, + runtime: 120, + year: 2024, + platforms: [] + ) + } + } + + private func assertThrowsError(_ expression: @autoclosure () async throws -> Void) async { + do { + _ = try await expression() + XCTFail("Expected expression to throw an error") + } catch { + // Expected + } + } +} + +// MARK: - Helper Extensions + +extension MediaItem { + func hasGenre(_ genreName: String) -> Bool { + return genres.contains { $0.name == genreName } + } +} diff --git a/apps/vibecheck-ios/VibeCheckTests/SommelierAgentTests.swift b/apps/vibecheck-ios/VibeCheckTests/SommelierAgentTests.swift new file mode 100644 index 00000000..236a93c2 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/SommelierAgentTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import VibeCheck + +final class SommelierAgentTests: XCTestCase { + + var agent: SommelierAgent! + + override func setUp() { + super.setUp() + agent = SommelierAgent.shared + } + + func testComfortRationale() { + // Given: User is tired/stressed (Comfort vibe) + let mood = MoodState(energy: .low, stress: .stressed) // "Comfort" + let context = VibeContext( + keywords: ["gentle", "familiar"], + explanation: "you are tired", + mood: mood + ) + + let item = MediaItem( + id: "1", title: "The Office", genres: ["Comedy"], + tone: ["feel-good"], intensity: 0.2, isRewatch: true + ) + + // When + let rationale = agent.rationale(for: item, context: context) + + // Then + XCTAssertTrue(rationale.contains("The Office")) + XCTAssertTrue(rationale.lowercased().contains("comfort") || rationale.lowercased().contains("favorite")) + } + + func testExcitingRationale() { + // Given: User is high energy/relaxed (Exciting vibe) + let mood = MoodState(energy: .high, stress: .relaxed) // "Exciting" + let context = VibeContext( + keywords: ["action", "fast"], + explanation: "you have energy", + mood: mood + ) + + let item = MediaItem( + id: "2", title: "Mad Max", genres: ["Action"], + tone: ["intense"], intensity: 0.9 + ) + + // When + let rationale = agent.rationale(for: item, context: context) + + // Then + XCTAssertTrue(rationale.contains("Mad Max")) + XCTAssertTrue(rationale.lowercased().contains("energy") || rationale.lowercased().contains("intensity")) + } +} diff --git a/apps/vibecheck-ios/VibeCheckTests/SommelierRunner.swift b/apps/vibecheck-ios/VibeCheckTests/SommelierRunner.swift new file mode 100644 index 00000000..ab06736c --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/SommelierRunner.swift @@ -0,0 +1,56 @@ +import Foundation + +// Rudimentary Test Runner +@main +class SommelierRunner { + static func main() { + print("Running SommelierAgent Tests...") + + let agent = SommelierAgent.shared + var failed = false + + // Test 1: Comfort Rationale + let comfortMood = MoodState(energy: .low, stress: .stressed) + let comfortContext = VibeContext( + keywords: ["gentle"], + explanation: "you are tired", + mood: comfortMood + ) + let comfortItem = MediaItem(id: "1", title: "The Office", tone: ["feel-good"], intensity: 0.2, isRewatch: true) + + let comfortRat = agent.rationale(for: comfortItem, context: comfortContext) + if comfortRat.contains("The Office") && (comfortRat.lowercased().contains("comfort") || comfortRat.lowercased().contains("favorite")) { + print("✅ testComfortRationale: PASSED") + } else { + print("❌ testComfortRationale: FAILED - Got: \(comfortRat)") + failed = true + } + + // Test 2: Exciting Rationale + let excitingMood = MoodState(energy: .high, stress: .relaxed) + let existingContext = VibeContext( + keywords: ["action"], + explanation: "you have energy", + mood: excitingMood + ) + let actionItem = MediaItem(id: "2", title: "Mad Max", tone: ["intense"], intensity: 0.9) + + let actionRat = agent.rationale(for: actionItem, context: existingContext) + if actionRat.contains("Mad Max") && (actionRat.lowercased().contains("energy") || actionRat.lowercased().contains("intensity") || actionRat.lowercased().contains("revs")) { + print("✅ testExcitingRationale: PASSED") + } else { + print("❌ testExcitingRationale: FAILED - Got: \(actionRat)") + failed = true + } + + if failed { + exit(1) + } else { + print("All Tests Passed.") + exit(0) + } + } +} + +// Entry point + diff --git a/apps/vibecheck-ios/VibeCheckTests/VerifyARWArtwork.swift b/apps/vibecheck-ios/VibeCheckTests/VerifyARWArtwork.swift new file mode 100644 index 00000000..bc95c9fe --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/VerifyARWArtwork.swift @@ -0,0 +1,51 @@ +import Foundation + +@main +struct VerifyARWArtwork { + static func main() async { + print("🖼️ Verifying ARW Artwork Fetching...") + + do { + // 1. Perform a real search (requires backend running) + // We use a query likely to return popular results with artwork + let results = try await ARWService.shared.search(query: "avatar") + + if results.isEmpty { + print("⚠️ No results found. Is the backend running?") + exit(1) + } + + print("✅ Found \(results.count) results.") + + var artworkCount = 0 + + for item in results { + print("\n🎬 \(item.title)") + if let poster = item.posterURL { + print(" - Poster: \(poster.absoluteString)") + artworkCount += 1 + } else { + print(" - Poster: ❌") + } + + if let backdrop = item.backdropURL { + print(" - Backdrop: \(backdrop.absoluteString)") + } else { + print(" - Backdrop: ❌") + } + } + + if artworkCount > 0 { + print("\n🎉 SUCCESS: Artwork URLs are being parsed correctly!") + print(" (Note: Ensure your UI displays them via AsyncImage)") + } else { + print("\n❌ FAILURE: No artwork URLs found in results.") + exit(1) + } + + } catch { + print("❌ Error: \(error)") + exit(1) + } + } +} diff --git a/apps/vibecheck-ios/VibeCheckTests/VerifySommelierIntegration.swift b/apps/vibecheck-ios/VibeCheckTests/VerifySommelierIntegration.swift new file mode 100644 index 00000000..250319f1 --- /dev/null +++ b/apps/vibecheck-ios/VibeCheckTests/VerifySommelierIntegration.swift @@ -0,0 +1,65 @@ +import Foundation +import Observation +import NaturalLanguage + +// Mock Types to avoid dragging in everything if possible +// But we need the real Engine logic. + +// We will rely on the real files being passed to swiftc. + +@main +class IntegrationVerifier { + static func main() async { + print("--- Verifying Sommelier Engine Integration ---") + + let engine = RecommendationEngine() + + // Mock Context: "Stressed and Low Energy" -> Comfort + let mood = MoodState(energy: .low, stress: .stressed) + let context = VibeContext( + keywords: ["gentle", "familiar", "warm"], + explanation: "you had a rough day", + mood: mood + ) + + // Preferences + let prefs = UserPreferences() + // We assume UserPreferences defaults are empty/permissive. + // We need to make sure UserPreferences is available or mocked. + // It's in `Models/UserPreferences.swift` (I need to check if I need to include it) + + print("Refreshing recommendations for mood: \(mood.recommendationHint)...") + + // We need to call refresh. It uses Task/MainActor. + // We are in async static main. + + // Note: Engine.refresh is async internally (Task { ... }) but changes state on MainActor. + // We can't await it easily unless we modify Engine to return values or wait. + // For this test, we might sleep. + + engine.refresh(context: context, preferences: prefs) + + // Wait for async task (ARW timeout is 5s, so we wait 6s to be safe) + try? await Task.sleep(nanoseconds: 6 * 1_000_000_000) + + let recs = engine.recommendations + print("Got \(recs.count) recommendations.") + + for item in recs { + print("\n🎬 \(item.title)") + if let rationale = item.sommelierRationale { + print("🍷 Sommelier: \"\(rationale)\"") + } else { + print("❌ NO RATIONAL GENERATED") + exit(1) + } + } + + if recs.isEmpty { + print("❌ No recs found.") + exit(1) + } + + print("\n✅ Integration Verification Passed.") + } +} diff --git a/apps/vibecheck-ios/docs/WASM-Integration-Plan.md b/apps/vibecheck-ios/docs/WASM-Integration-Plan.md new file mode 100644 index 00000000..565a5385 --- /dev/null +++ b/apps/vibecheck-ios/docs/WASM-Integration-Plan.md @@ -0,0 +1,528 @@ +# WASM Integration for VibeCheck iOS - Implementation Plan + +## Goal + +Integrate rUv's ultra-high-performance self-learning recommendation engine (currently achieving 9M reads/sec, 12M writes/sec) into the VibeCheck iOS app via WebAssembly. This will enable: + +1. **On-device learning** - No network round-trips for recommendations +2. **Privacy-first** - All health data and learning stays local +3. **Ultra-fast performance** - CPU-optimized WASM vs traditional GPU-heavy ML +4. **Self-learning** - Q-learning, vector embeddings, and attention mechanisms running locally + +## Background Context + +From the hackathon transcript, rUv proposed embedding his recommendation engine (written in Rust with custom kernels) into the iOS app via WASM to: + +> "Embed the complex ML graph and attention and AI models directly in the WASM, then you can also utilize both the GPU and CPU cycles of the phone itself. You can do all that learning local." + +His system uses: +- Hyperbolic vector embeddings (384-dimensional) +- Q-learning for adaptive recommendations +- Self-attention mechanisms +- BLAKE2b-512 cryptographic hashing +- Custom kernels optimized for CPU performance + +## User Review Required + +> [!IMPORTANT] +> **Coordination with rUv Required** +> +> This plan assumes we can obtain rUv's Rust-based recommendation engine source code and compile it to WASM. We need to: +> 1. Request access to his vector database and recommendation system code +> 2. Confirm it can be compiled to `wasm32-wasi` or `wasm32-unknown-unknown` +> 3. Define the interface/API between Swift and WASM module + +> [!WARNING] +> **Breaking Change to Current Architecture** +> +> This will replace the current ARW-based backend recommendations with a hybrid approach: +> - **On-device WASM**: Self-learning recommendations based on local viewing history + health data +> - **ARW Backend**: Content discovery and metadata enrichment +> +> The app will shift from server-dependent to primarily local-first. + +## Proposed Changes + +### Component 1: WASM Runtime Integration + +#### [NEW] `Package.swift` Dependencies + +Add WasmKit as a Swift Package Manager dependency: + +```swift +dependencies: [ + .package(url: "https://github.com/swiftwasm/WasmKit.git", from: "0.1.0") +] +``` + +**Rationale**: WasmKit is pure Swift, supports iOS 12.0+, and provides WASI support. It's more native than Wasmer and doesn't require C bindings. + +--- + +### Component 2: WASM Module Wrapper + +#### [NEW] `WasmRecommendationEngine.swift` + +Create a Swift wrapper for the WASM recommendation engine: + +```swift +import WasmKit + +class WasmRecommendationEngine { + private let runtime: Runtime + private let module: Module + private let instance: Instance + + // WASM function imports + private var embedContent: Function? + private var computeSimilarity: Function? + private var updateLearning: Function? + private var getRecommendations: Function? + + init(wasmPath: URL) throws { + // Initialize WasmKit runtime + runtime = Runtime() + + // Load WASM module from bundle + let wasmData = try Data(contentsOf: wasmPath) + module = try Module(bytes: Array(wasmData)) + + // Instantiate with imports + instance = try module.instantiate(runtime: runtime) + + // Bind exported functions + embedContent = instance.exports["embed_content"] as? Function + computeSimilarity = instance.exports["compute_similarity"] as? Function + updateLearning = instance.exports["update_learning"] as? Function + getRecommendations = instance.exports["get_recommendations"] as? Function + } + + func embed(content: ContentMetadata) async throws -> [Float] { + // Convert Swift struct to WASM memory + // Call embed_content function + // Return vector embedding + } + + func recommend(basedOn vibe: VibeState, history: [ContentID]) async throws -> [Recommendation] { + // Pass vibe + history to WASM + // Get recommendations with confidence scores + // Return sorted recommendations + } + + func learn(interaction: UserInteraction) async throws { + // Update Q-learning model with user feedback + // Adjust attention weights + // Persist learning state to local storage + } +} +``` + +**Key Design Decisions**: +- Async/await for WASM calls (may be CPU-intensive) +- Memory management between Swift and WASM linear memory +- Serialization format (JSON, MessagePack, or custom binary) + +--- + +### Component 3: Health Data Integration + +#### [MODIFY] `HealthKitManager.swift` + +Extend to provide vibe data to WASM engine: + +```swift +extension HealthKitManager { + func getVibeVector() async -> [Float] { + // Convert health metrics to numerical vector + // [heart_rate_normalized, activity_level, sleep_quality, stress_level] + // This becomes input to WASM recommendation engine + } +} +``` + +--- + +### Component 4: Hybrid Recommendation Strategy + +#### [NEW] `HybridRecommendationService.swift` + +Orchestrate between WASM (local) and ARW (remote): + +```swift +actor HybridRecommendationService { + private let wasmEngine: WasmRecommendationEngine + private let arwClient: ARWMediaClient + private let healthKit: HealthKitManager + + func getRecommendations() async throws -> [ContentRecommendation] { + // 1. Get current vibe from HealthKit + let vibeVector = await healthKit.getVibeVector() + + // 2. Get local WASM recommendations (fast, privacy-first) + let localRecs = try await wasmEngine.recommend( + basedOn: vibeVector, + history: getLocalHistory() + ) + + // 3. If local catalog is insufficient, query ARW for discovery + if localRecs.count < 10 { + let remoteRecs = try await arwClient.discover(vibe: vibeVector) + // Merge and deduplicate + } + + // 4. Return hybrid results + return localRecs + remoteRecs + } + + func recordInteraction(_ interaction: UserInteraction) async { + // Update WASM learning model + try? await wasmEngine.learn(interaction: interaction) + + // Optionally sync anonymized learning to ARW (future) + } +} +``` + +--- + +### Component 5: WASM Module Build Pipeline + +#### [NEW] `wasm-build/` Directory Structure + +``` +wasm-build/ +├── Cargo.toml # Rust project for recommendation engine +├── src/ +│ ├── lib.rs # WASM exports +│ ├── embeddings.rs # Vector embedding logic +│ ├── qlearning.rs # Q-learning implementation +│ └── attention.rs # Self-attention mechanism +├── build.sh # Compile to wasm32-wasi +└── recommendation.wasm # Output (bundled with iOS app) +``` + +**Build Script** (`build.sh`): +```bash +#!/bin/bash +cargo build --target wasm32-wasi --release +wasm-opt -Oz -o recommendation.wasm target/wasm32-wasi/release/recommendation_engine.wasm +cp recommendation.wasm ../vibecheck-ios/Resources/ +``` + +--- + +### Component 6: iOS App Integration + +#### [MODIFY] `ForYouView.swift` + +Replace current recommendation logic: + +```swift +struct ForYouView: View { + @StateObject private var recommendationService = HybridRecommendationService() + + var body: some View { + ScrollView { + // Vibe indicator (from HealthKit) + VibeIndicator(vibe: recommendationService.currentVibe) + + // WASM-powered recommendations + ForEach(recommendationService.recommendations) { rec in + ContentCard(content: rec) + .onTapGesture { + // Record interaction for learning + Task { + await recommendationService.recordInteraction(.viewed(rec.id)) + } + } + } + } + .task { + await recommendationService.loadRecommendations() + } + } +} +``` + +--- + +### Component 7: Data Persistence + +#### [NEW] `WasmStateManager.swift` + +Persist WASM learning state between app launches: + +```swift +actor WasmStateManager { + private let stateURL: URL + + func saveState(_ memory: Data) async throws { + // Save WASM linear memory snapshot + // Includes learned weights, Q-tables, embeddings cache + try memory.write(to: stateURL) + } + + func loadState() async throws -> Data? { + // Restore WASM state on app launch + guard FileManager.default.fileExists(atPath: stateURL.path) else { + return nil + } + return try Data(contentsOf: stateURL) + } +} +``` + +--- + +## Architecture Diagram + +```mermaid +graph TB + subgraph "iOS App" + UI[ForYouView] + HRS[HybridRecommendationService] + HK[HealthKitManager] + WRE[WasmRecommendationEngine] + ARW[ARWMediaClient] + WSM[WasmStateManager] + end + + subgraph "WASM Module" + EMB[Embeddings] + QL[Q-Learning] + ATT[Attention] + VEC[Vector DB] + end + + subgraph "External" + HEALTH[HealthKit] + BACKEND[ARW Backend] + STORAGE[Local Storage] + end + + UI --> HRS + HRS --> WRE + HRS --> ARW + HRS --> HK + HK --> HEALTH + WRE --> EMB + WRE --> QL + WRE --> ATT + WRE --> VEC + WRE --> WSM + WSM --> STORAGE + ARW --> BACKEND +``` + +--- + +## Verification Plan + +### Phase 1: WASM Runtime Setup + +**Test 1: WasmKit Integration** +```bash +# In vibecheck-ios directory +swift package resolve +swift build +``` +**Expected**: WasmKit dependency resolves and builds successfully. + +**Test 2: Load Sample WASM Module** +- Create minimal "Hello World" WASM module +- Load in `WasmRecommendationEngine` initializer +- Verify no crashes, module instantiates + +**Manual Test**: Run app in Xcode simulator, check console for WASM load success message. + +--- + +### Phase 2: Nolan's Engine Integration + +**Test 3: Compile Recommendation Engine to WASM** +```bash +cd wasm-build +./build.sh +ls -lh recommendation.wasm +``` +**Expected**: WASM file under 5MB (optimized with wasm-opt). + +**Test 4: Function Binding** +- Call each exported WASM function from Swift +- Verify correct parameter passing and return values +- Test with sample content metadata + +**Manual Test**: +1. Open VibeCheck app +2. Navigate to "For You" tab +3. Verify recommendations appear (even if random at first) +4. Check Xcode console for WASM function call logs + +--- + +### Phase 3: Health Data Integration + +**Test 5: Vibe Vector Generation** +```swift +// In HealthKitManagerTests.swift +func testVibeVectorGeneration() async throws { + let manager = HealthKitManager() + let vector = await manager.getVibeVector() + + XCTAssertEqual(vector.count, 4) // [heart_rate, activity, sleep, stress] + XCTAssert(vector.allSatisfy { $0 >= 0.0 && $0 <= 1.0 }) // Normalized +} +``` + +**Manual Test**: +1. Grant HealthKit permissions +2. Record some activity (walk around) +3. Check that vibe indicator updates in app +4. Verify recommendations change based on activity level + +--- + +### Phase 4: Learning & Persistence + +**Test 6: Q-Learning Updates** +- Simulate user interactions (watch, skip, like) +- Verify WASM learning state updates +- Check that recommendations improve over time + +**Test 7: State Persistence** +```swift +func testStatePersistence() async throws { + let manager = WasmStateManager() + let testData = Data([1, 2, 3, 4, 5]) + + try await manager.saveState(testData) + let loaded = try await manager.loadState() + + XCTAssertEqual(testData, loaded) +} +``` + +**Manual Test**: +1. Use app for 10 interactions (watch/skip content) +2. Force quit app +3. Relaunch app +4. Verify recommendations reflect previous learning (not reset) + +--- + +### Phase 5: Performance Benchmarking + +**Test 8: WASM vs CoreML Speed** +```swift +func testRecommendationSpeed() async throws { + let wasmEngine = WasmRecommendationEngine() + let start = Date() + + let recs = try await wasmEngine.recommend(basedOn: testVibe, history: testHistory) + + let duration = Date().timeIntervalSince(start) + XCTAssertLessThan(duration, 0.1) // Should be under 100ms +} +``` + +**Expected**: Sub-100ms recommendations on iPhone 12 or newer. + +--- + +### Phase 6: Integration Testing + +**Test 9: End-to-End Recommendation Flow** +1. Launch app with fresh install +2. Grant HealthKit permissions +3. Browse content for 5 minutes +4. Verify "For You" tab shows personalized recommendations +5. Interact with content (watch, skip) +6. Verify recommendations adapt in real-time + +**Test 10: Offline Functionality** +1. Enable Airplane Mode +2. Open app +3. Verify WASM recommendations still work (no network needed) +4. ARW discovery should gracefully fail with cached content + +--- + +## Dependencies & Prerequisites + +### Required from rUv +- [ ] Rust source code for recommendation engine +- [ ] API specification (function signatures, data formats) +- [ ] Sample training data or pre-trained model weights +- [ ] Performance benchmarks to validate iOS performance + +### Development Environment +- [ ] Rust toolchain with `wasm32-wasi` target +- [ ] `wasm-opt` from Binaryen toolkit +- [ ] Xcode 15+ (for Swift 6.1 WASM support) +- [ ] iOS 17+ deployment target (or 12+ with WasmKit) + +### App Changes +- [ ] Increase app bundle size budget (~5-10MB for WASM module) +- [ ] Update privacy policy (on-device ML, health data usage) +- [ ] Add HealthKit usage descriptions to Info.plist + +--- + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| WASM module too large | App Store rejection | Use `wasm-opt -Oz`, strip debug symbols, lazy-load | +| Performance worse than CoreML | Poor UX | Benchmark early, fallback to CoreML if needed | +| Memory constraints on older devices | Crashes | Implement memory monitoring, reduce model size | +| rUv's code not WASM-compatible | Blocker | Start with minimal WASM prototype, validate early | +| Learning state corruption | Data loss | Versioned state files, migration logic | + +--- + +## Timeline Estimate + +| Phase | Duration | Dependencies | +|-------|----------|--------------| +| WASM Runtime Setup | 2 days | WasmKit integration | +| rUv's Engine Port | 5 days | Rust code from rUv | +| Health Data Integration | 2 days | HealthKit permissions | +| Learning & Persistence | 3 days | WASM state management | +| Testing & Optimization | 3 days | Real device testing | +| **Total** | **15 days** | Assumes rUv's code available | + +--- + +## Success Metrics + +- [ ] Recommendations generated in <100ms (vs current ~500ms network round-trip) +- [ ] 80%+ accuracy in vibe-based recommendations (rUv's benchmark) +- [ ] Zero network requests for core recommendation flow +- [ ] Learning state persists across app launches +- [ ] Memory usage <50MB for WASM engine +- [ ] Works offline with cached content catalog + +--- + +## Future Enhancements + +1. **Family Vibe Mesh via WASM**: Share learned preferences across family members using CloudKit + WASM state sync +2. **Federated Learning**: Aggregate anonymized learning across users while preserving privacy +3. **Multi-Modal Embeddings**: Combine health data, viewing history, and contextual signals (time of day, location) +4. **WASM Hot-Reload**: Update recommendation model without app update (download new WASM module) + +--- + +## Open Questions + +1. **Serialization Format**: Should we use JSON, MessagePack, or custom binary for Swift ↔ WASM communication? +2. **Model Size**: What's the minimum viable model size for acceptable recommendations? +3. **Update Frequency**: How often should WASM learning state be persisted? (Every interaction vs batched) +4. **Fallback Strategy**: If WASM fails, fall back to CoreML or ARW backend? +5. **Testing on Real Devices**: Do we have access to iPhone 12, 13, 14, 15 for performance testing? + +--- + +*This plan assumes collaboration with rUv to obtain and adapt his recommendation engine. Next step: Request rUv's code and define the WASM API contract.* + +*Source: https://gist.github.com/michaeloboyle/b768dd2a80b2dd521d4552d2d8f1e8a1* diff --git a/apps/vibecheck-ios/docs/screenshots/benchmark-data-context.HEIC b/apps/vibecheck-ios/docs/screenshots/benchmark-data-context.HEIC new file mode 100644 index 00000000..8cedf1e8 Binary files /dev/null and b/apps/vibecheck-ios/docs/screenshots/benchmark-data-context.HEIC differ diff --git a/apps/vibecheck-ios/docs/screenshots/benchmark-r16.png b/apps/vibecheck-ios/docs/screenshots/benchmark-r16.png new file mode 100644 index 00000000..f59f1926 Binary files /dev/null and b/apps/vibecheck-ios/docs/screenshots/benchmark-r16.png differ diff --git a/apps/vibecheck-ios/docs/screenshots/benchmark-results-with-context.HEIC b/apps/vibecheck-ios/docs/screenshots/benchmark-results-with-context.HEIC new file mode 100644 index 00000000..031a439b Binary files /dev/null and b/apps/vibecheck-ios/docs/screenshots/benchmark-results-with-context.HEIC differ diff --git a/apps/vibecheck-ios/docs/screenshots/benchmark-results.HEIC b/apps/vibecheck-ios/docs/screenshots/benchmark-results.HEIC new file mode 100644 index 00000000..8d34f754 Binary files /dev/null and b/apps/vibecheck-ios/docs/screenshots/benchmark-results.HEIC differ diff --git a/apps/vibecheck-ios/docs/screenshots/for-you-tab.HEIC b/apps/vibecheck-ios/docs/screenshots/for-you-tab.HEIC new file mode 100644 index 00000000..97f16f06 Binary files /dev/null and b/apps/vibecheck-ios/docs/screenshots/for-you-tab.HEIC differ diff --git a/apps/vibecheck-ios/docs/screenshots/foryou-mellow-r16.png b/apps/vibecheck-ios/docs/screenshots/foryou-mellow-r16.png new file mode 100644 index 00000000..258578ce Binary files /dev/null and b/apps/vibecheck-ios/docs/screenshots/foryou-mellow-r16.png differ diff --git a/apps/vibecheck-ios/docs/screenshots/foryou-r16.png b/apps/vibecheck-ios/docs/screenshots/foryou-r16.png new file mode 100644 index 00000000..ee710a6c Binary files /dev/null and b/apps/vibecheck-ios/docs/screenshots/foryou-r16.png differ diff --git a/apps/vibecheck-ios/docs/screenshots/settings-view.HEIC b/apps/vibecheck-ios/docs/screenshots/settings-view.HEIC new file mode 100644 index 00000000..323f4d99 Binary files /dev/null and b/apps/vibecheck-ios/docs/screenshots/settings-view.HEIC differ diff --git a/apps/vibecheck-ios/docs/screenshots/vibe-check-tab.HEIC b/apps/vibecheck-ios/docs/screenshots/vibe-check-tab.HEIC new file mode 100644 index 00000000..f2dc7561 Binary files /dev/null and b/apps/vibecheck-ios/docs/screenshots/vibe-check-tab.HEIC differ diff --git a/apps/vibecheck-ios/integrate_ruvector_xcode.sh b/apps/vibecheck-ios/integrate_ruvector_xcode.sh new file mode 100755 index 00000000..6ea1ecd2 --- /dev/null +++ b/apps/vibecheck-ios/integrate_ruvector_xcode.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# +# integrate_ruvector_xcode.sh +# Automates Xcode integration steps for Ruvector +# + +set -e + +PROJECT_DIR="/Volumes/black box/github/pkm/hackathon-tv5/apps/vibecheck-ios" +cd "$PROJECT_DIR" + +echo "🔧 Ruvector Xcode Integration" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +# Step 1: Verify files exist +echo "📦 Step 1: Verifying files..." +if [ ! -f "VibeCheck/Engine/RuvectorBridge.swift" ]; then + echo "❌ RuvectorBridge.swift not found" + exit 1 +fi + +if [ ! -f "VibeCheck/Resources/ruvector.wasm" ]; then + echo "❌ ruvector.wasm not found" + exit 1 +fi + +if [ ! -f "VibeCheckTests/RuvectorBridgeTests.swift" ]; then + echo "❌ RuvectorBridgeTests.swift not found" + exit 1 +fi + +echo "✅ All files present" +echo "" + +# Step 2: Add Package.swift for WasmKit dependency +echo "📦 Step 2: Creating Package.swift for WasmKit..." + +cat > Package.swift << 'PKGEOF' +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "VibeCheck", + platforms: [ + .iOS(.v17) + ], + dependencies: [ + .package(url: "https://github.com/swiftwasm/WasmKit", from: "0.1.0") + ], + targets: [ + .target( + name: "VibeCheck", + dependencies: [ + .product(name: "WasmKit", package: "WasmKit") + ] + ) + ] +) +PKGEOF + +echo "✅ Package.swift created" +echo "" + +# Step 3: Instructions for manual Xcode steps +echo "📋 Step 3: Manual Xcode Steps Required" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "Please complete these steps in Xcode:" +echo "" +echo "1️⃣ Add WasmKit Package:" +echo " • File → Add Package Dependencies" +echo " • URL: https://github.com/swiftwasm/WasmKit" +echo " • Version: 0.1.0 or later" +echo " • Add to target: VibeCheck" +echo "" +echo "2️⃣ Add Source Files:" +echo " • Right-click 'VibeCheck/Engine' folder" +echo " • Add Files → Select 'RuvectorBridge.swift'" +echo " • ✅ Copy items if needed" +echo " • ✅ Add to targets: VibeCheck" +echo "" +echo " • Right-click 'VibeCheckTests' folder" +echo " • Add Files → Select 'RuvectorBridgeTests.swift'" +echo " • ✅ Add to targets: VibeCheckTests" +echo "" +echo "3️⃣ Add WASM Resource:" +echo " • Right-click 'VibeCheck/Resources' folder" +echo " • Add Files → Select 'ruvector.wasm'" +echo " • ✅ Copy items if needed" +echo " • ✅ Add to targets: VibeCheck" +echo "" +echo "4️⃣ Build & Test:" +echo " • Press ⌘+B to build" +echo " • Press ⌘+U to run tests" +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "✅ Setup complete!" +echo " Next: Open VibeCheck.xcodeproj and follow steps above"