Skip to content

Add Apple Watch complications for device monitoring, quick recording, and ask questions#4824

Open
barrettj wants to merge 39 commits intoBasedHardware:mainfrom
barrettj:watch-complication
Open

Add Apple Watch complications for device monitoring, quick recording, and ask questions#4824
barrettj wants to merge 39 commits intoBasedHardware:mainfrom
barrettj:watch-complication

Conversation

@barrettj
Copy link

Summary

  • Adds three WidgetKit-based Apple Watch complications: Omi Device Monitor (battery/recording status with configurable tap action), Quick Record (one-tap watch mic recording), and Ask Omi (record a question, get answer via notification)
  • Implements full data pipeline: watch app writes state to App Group UserDefaults → widget reads it; Flutter forwards Omi device state → iOS → WCSession → watch → SharedState → widget refresh
  • Adds Flutter handler for processing voice questions from the watch (PCM→WAV→/v2/voice-messages→local notification with answer)

Setup Required

Before this works in production, the following Apple Developer portal setup is needed:

  • App Group: Create group.com.omi.watchapp and add it to both the watch app and widget extension provisioning profiles
  • Widget Extension Provisioning Profile: Create a provisioning profile for the omiWatchAppComplication target with bundle ID $(APP_BUNDLE_IDENTIFIER).watchapp.complication
  • The entitlements files for both targets reference group.com.omi.watchapp

Testing Notes

I have not been able to fully test this due to the nature of iOS development requiring App Store Connect provisioning and team membership. I am on the TestFlight and am also the user who requested a complication feature on Reddit. Widget complications require a physical Apple Watch paired to a device with proper provisioning to fully validate.

The WidgetKit extension scheme (omiWatchAppComplication) compiles with 0 errors against the existing Xcode project.

Test plan

  • Configure App Group group.com.omi.watchapp in Apple Developer portal
  • Add App Group to watch app and widget extension provisioning profiles
  • Build and install on a paired Apple Watch
  • Verify Device Monitor complication shows battery level and recording status
  • Verify Quick Record complication opens watch app and starts recording
  • Verify Ask Omi complication records a question and receives answer notification
  • Verify configurable tap action on Device Monitor widget works (start device recording, start watch recording, ask question, open app)
  • Verify device state updates flow from Omi hardware → Flutter → iOS → Watch → widget refresh

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Apple Watch complications for device monitoring, quick recording, and asking questions. The implementation uses WidgetKit and establishes a data pipeline between the Flutter app, iOS host, and the watchOS targets. The overall approach is solid, but I've identified a few significant issues. A hardcoded SDK path in the project file will break the build for other developers. The 'Start Device Recording' complication action is not implemented correctly and triggers the wrong action. Additionally, there's duplicated code for shared state management between the watch app and its complication, which poses a maintainability risk. Addressing these points will improve the robustness and correctness of this new feature.

4B5B6E291EA70190D1771F2D /* Pods-Runner.release-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-dev.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-dev.xcconfig"; sourceTree = "<group>"; };
51B2C0C6DC8817A51E8E80AF /* omiWatchAppComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = omiWatchAppComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
556416C8748DD3DD2C06A9DB /* devProfile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = devProfile.xcconfig; path = Flutter/devProfile.xcconfig; sourceTree = "<group>"; };
5EC363DC52C4728F4BC45476 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The project file contains a hardcoded path to WatchOS11.0.sdk. This path is specific to your local development environment and will cause the build to fail for other developers who may have different Xcode or SDK versions installed. Project settings should use environment variables like SDKROOT or relative paths to ensure portability.

Comment on lines +94 to +96
if autoStartDeviceRecording {
viewModel.session.sendMessage(["method": "startRecording"], replyHandler: nil, errorHandler: nil)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The autoStartDeviceRecording feature is implemented by sending a startRecording message to the phone. However, on the iOS app side (AppDelegate.swift), the startRecording message is handled as a notification that the watch has started recording, not as a command to start recording on the Omi device. This means the "Start Device Recording" complication action will incorrectly start a watch recording instead.

To fix this, a new WCSession message should be introduced, for example, startDeviceRecording. The watch app should send this new message when autoStartDeviceRecording is true. The AppDelegate on the phone should then handle this new message and trigger the appropriate action to start recording on the Omi device.

Suggested change
if autoStartDeviceRecording {
viewModel.session.sendMessage(["method": "startRecording"], replyHandler: nil, errorHandler: nil)
}
if autoStartDeviceRecording {
viewModel.session.sendMessage(["method": "startDeviceRecording"], replyHandler: nil, errorHandler: nil)
}

Comment on lines +1 to +67
// Created by Barrett Jacobsen

import Foundation
import WidgetKit

struct SharedState {
static let suiteName = "group.com.omi.watchapp"

private static var defaults: UserDefaults? {
UserDefaults(suiteName: suiteName)
}

// MARK: - Watch Recording State

static var isWatchRecording: Bool {
get { defaults?.bool(forKey: "isWatchRecording") ?? false }
set {
defaults?.set(newValue, forKey: "isWatchRecording")
defaults?.set(Date(), forKey: "lastUpdated")
WidgetCenter.shared.reloadAllTimelines()
}
}

// MARK: - Device State (forwarded from iPhone via WatchConnectivity)

static var isDeviceRecording: Bool {
get { defaults?.bool(forKey: "isDeviceRecording") ?? false }
set {
defaults?.set(newValue, forKey: "isDeviceRecording")
defaults?.set(Date(), forKey: "lastUpdated")
WidgetCenter.shared.reloadAllTimelines()
}
}

static var isDeviceConnected: Bool {
get { defaults?.bool(forKey: "isDeviceConnected") ?? false }
set {
defaults?.set(newValue, forKey: "isDeviceConnected")
defaults?.set(Date(), forKey: "lastUpdated")
WidgetCenter.shared.reloadAllTimelines()
}
}

static var deviceBatteryLevel: Float {
get { defaults?.float(forKey: "deviceBatteryLevel") ?? -1 }
set {
defaults?.set(newValue, forKey: "deviceBatteryLevel")
defaults?.set(Date(), forKey: "lastUpdated")
WidgetCenter.shared.reloadAllTimelines()
}
}

// MARK: - Ask Omi State

static var lastQuestionStatus: String {
get { defaults?.string(forKey: "lastQuestionStatus") ?? "idle" }
set {
defaults?.set(newValue, forKey: "lastQuestionStatus")
defaults?.set(Date(), forKey: "lastUpdated")
WidgetCenter.shared.reloadAllTimelines()
}
}

static var lastUpdated: Date {
defaults?.object(forKey: "lastUpdated") as? Date ?? .distantPast
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The SharedState.swift file is duplicated in both the omiWatchApp and omiWatchAppComplication targets. This creates a maintainability issue, as any changes to the shared state logic must be manually synchronized between the two files. A discrepancy between them could lead to subtle bugs where the widget and the watch app behave differently.

This shared code should be moved into a shared framework that both the watch app and the widget extension can link against. This ensures there is a single source of truth for the shared state logic.

@barrettj
Copy link
Author

Review Fixes Applied

Addressed all review findings from Gemini and additional code review:

Critical Fixes

  • Fix: BatteryManager overwriting Omi device battery with Watch battery — Removed SharedState.deviceBatteryLevel = Float(level) from sendBatteryLevel() which was clobbering Omi device battery data every 3 minutes
  • Fix: Hardcoded WatchOS11.0.sdk path — Changed to SDKROOT-relative path so builds work across Xcode versions
  • Fix: Wrong message for "Start Device Recording" complication — Changed from startRecording to startDeviceRecording with full pipeline: Pigeon API, AppDelegate handler, bridge callback, transport wiring
  • Fix: Triple widget timeline reload — Added SharedState.updateDeviceState() batch method, reducing 3 reloadAllTimelines() calls to 1
  • Fix: Temp file leak on error — Added finally block in _handleAskQuestion to clean up PCM and WAV files regardless of success/failure

Architecture Improvements

  • Deduplicated SharedState.swift — Moved from two identical copies (omiWatchApp + omiWatchAppComplication) into shared omiShared/ directory, referenced by both targets via fileSystemSynchronizedGroups

Minor Fixes

  • Fixed import ordering in main.dart (moved to services group)
  • Fixed WAV filename using DateTime.now().millisecondsSinceEpoch instead of non-unique hashCode

@mdmohsin7
Copy link
Member

demo pls

@barrettj
Copy link
Author

barrettj commented Feb 16, 2026 via email

@beastoin beastoin closed this Feb 17, 2026
@github-actions
Copy link
Contributor

Hey @barrettj 👋

Thank you so much for taking the time to contribute to Omi! We truly appreciate you putting in the effort to submit this pull request.

After careful review, we've decided not to merge this particular PR. Please don't take this personally — we genuinely try to merge as many contributions as possible, but sometimes we have to make tough calls based on:

  • Project standards — Ensuring consistency across the codebase
  • User needs — Making sure changes align with what our users need
  • Code best practices — Maintaining code quality and maintainability
  • Project direction — Keeping aligned with our roadmap and vision

Your contribution is still valuable to us, and we'd love to see you contribute again in the future! If you'd like feedback on how to improve this PR or want to discuss alternative approaches, please don't hesitate to reach out.

Thank you for being part of the Omi community! 💜

@beastoin
Copy link
Collaborator

Hey, friendly nudge — could you add a demo or end-to-end testing evidence to this PR? Screenshots, video, terminal output showing it working, anything that proves it runs correctly.

In the AI era, generating code is the easy part — what really matters is showing it works. A solid demo or test run gives reviewers the confidence to merge quickly. Without it, PRs tend to sit in the queue longer than they need to.

Thanks for contributing!

@barrettj
Copy link
Author

barrettj commented Feb 17, 2026 via email

@barrettj
Copy link
Author

barrettj commented Feb 17, 2026 via email

@beastoin
Copy link
Collaborator

Reopening — you were right. watchOS complications genuinely cannot be demoed without TestFlight provisioning profiles and proper app group/bundle ID configuration. Our demo policy didn't account for platform-specific constraints, and that's on us. We're reopening this and will review on the code merits. Sorry for the dismissal.

@beastoin beastoin reopened this Feb 24, 2026
@beastoin beastoin added the waiting-on-maintainer Stalled on maintainer action — do not close until unblocked label Feb 24, 2026
@beastoin
Copy link
Collaborator

Thanks for your patience, Barrett, and again — sincere apologies for the earlier closure. That was a mistake on our part, and we appreciate you sticking with us.

We've now done a thorough code review of the full 2,956-line diff. Your watchOS architecture is well-structured — the complication family coverage (.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner) is comprehensive, the WCSession transport layer is cleanly separated, and the timeline provider logic follows Apple's best practices. It's clear this was built by someone who knows the platform deeply.

That said, we found several items that need attention before merge. We're flagging these in the spirit of getting this to a solid, shippable state — not gatekeeping.


Critical

1. App Group entitlements not wired to watch targets

The entitlements files exist (omiWatchApp.entitlements, omiWatchAppComplication.entitlements) and correctly declare group.com.omi.watchapp, but the Xcode project's CODE_SIGN_ENTITLEMENTS build settings for the watch targets don't reference these files. Without that linkage, UserDefaults(suiteName: "group.com.omi.watchapp") calls will silently return nil at runtime, which means complications won't receive real data from the phone app. They'd render but always show placeholder/default values.

2. "Start Device Recording" not implemented end-to-end

The watch sends a startRecording message via WCSession, and AppDelegate.swift receives it on the phone side — but the Flutter-side callback in watch_transport.dart only logs the event. It doesn't actually trigger recording. So the complication button exists and the message is delivered, but nothing happens on the phone. The recording action is wired in name but not in behavior.


High

3. Ask-question reply handler mismatch

AskQuestionView.swift (around line 159) sends messages using sendMessage(_:replyHandler:errorHandler:), which expects the receiving side to call the reply handler. However, AppDelegate.swift (around line 333) only implements session(_:didReceiveMessage:) — the variant without a reply handler. WatchConnectivity treats these as separate message channels. The practical effect: the watch side's replyHandler never gets called, the error handler fires after a timeout, and the user sees "Send failed" even though the message was actually delivered successfully.

4. Missing dependency

watch_ask_question_service.dart imports flutter_local_notifications, but this package isn't declared in pubspec.yaml. This will cause a build failure.


Medium

5. Device state updates may be stale

The sendDeviceState call isn't awaited, and state updates only fire on battery level or connection status changes — not on recording state transitions. Complications displaying recording status could lag behind the actual state, which would be confusing for users who just tapped "Start Recording" and glance at their watch face.

6. Unbounded audio accumulation

AskQuestionView.swift accumulates audio data in memory (audioData.append(buffer)) without any size cap. The Apple Watch has very limited memory — a long recording session could cause memory pressure warnings or termination by the system. A reasonable upper bound (e.g., 30 seconds worth of audio, or ~500KB) with graceful truncation would make this robust.

7. Xcode project version bump to 77

The project.pbxproj objectVersion is set to 77. This requires Xcode 16+, which may break CI pipelines or prevent contributors on older Xcode versions from building. Worth confirming this aligns with the project's minimum Xcode version policy.


Rebase

8. Branch drift — 657 commits behind main

Only 3 files overlap with recent main changes (project.pbxproj, AppDelegate.swift, main.dart), so a rebase should be manageable. The project.pbxproj merge will need the most care since it's a notoriously unfriendly file for conflict resolution, but the watch target additions are in distinct sections from recent main changes.


On demoing watchOS complications

We understand the complexity of watchOS provisioning — complication rendering on a real device does require proper entitlements and provisioning profiles that aren't trivial to set up for external reviewers. For what it's worth, Xcode's watchOS simulator paired with an iOS simulator can demo complication rendering and WCSession message flow without needing TestFlight distribution. That might be a lighter-weight path for showing the feature working — a short screen recording of the simulator showing a complication updating and a message round-trip would go a long way for reviewers who aren't set up for watchOS development. Totally understand if that's not straightforward either, just offering it as an option.


We're genuinely excited about this feature — watchOS complications would be a meaningful addition to Omi. We're committed to getting this merged and happy to help work through any of these items. Let us know if any of the findings above need clarification or if you see them differently.

@beastoin
Copy link
Collaborator

@barrettj Some pointers on the items mentioned above:

Entitlements wiring: The .entitlements files exist but the Xcode project's CODE_SIGN_ENTITLEMENTS build setting isn't set for the watch app or complication targets. You'll need to add that setting in each build configuration for both omiWatchApp and omiWatchAppComplication targets in project.pbxproj.

Recording flow: onDeviceRecordingRequestedCb in watch_transport.dart currently only logs. The actual recording entry point you'd want to connect to is in CaptureProvider — look at how streamDeviceRecording is called from the existing device flow.

Reply handler: AskQuestionView.swift sends with a replyHandler, but AppDelegate only implements didReceiveMessage without the reply variant. Either add the reply-handler overload on the iOS side, or switch to sendMessage without replyHandler on the watch side.

Missing dep: watch_ask_question_service.dart imports flutter_local_notifications but it's not in pubspec.yaml.

Rebase: ~657 behind main, but only 3 files overlap so it should be manageable.

Let us know if you have questions on any of these.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-on-maintainer Stalled on maintainer action — do not close until unblocked

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants