diff --git a/CLAUDE.md b/CLAUDE.md index 08e4531..e625607 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,9 +3,9 @@ ## Project Overview This is a SwiftUI-based presentation remote system with three apps: -- **Mac App** (`ClickerRemoteReceiver` v1.8): Menu bar app that receives commands and sends keystrokes to presentation software -- **iPhone App** (`ClickerRemote` v1.8): Remote control with vertical slide navigation and presentation timer -- **Apple Watch App** (`ClickerWatch` v1.8): Companion watch app with gesture-based slide control using double tap motion for next slide +- **Mac App** (`ClickerRemoteReceiver` v1.10): Menu bar app that receives commands and sends keystrokes to presentation software +- **iPhone App** (`ClickerRemote` v1.10): Remote control with vertical slide navigation and presentation timer +- **Apple Watch App** (`ClickerWatch` v1.10): Companion watch app with gesture-based slide control using double tap motion for next slide ## Tech Stack @@ -64,7 +64,7 @@ The notarized DMG is created at `./build/ClickerRemoteReceiver-{version}.dmg`. **Create GitHub release and update Homebrew tap:** ```bash # Create the release -gh release create v1.8 ./build/ClickerRemoteReceiver-1.8.dmg --title 'Clicker v1.8' --notes 'Release notes' +gh release create v1.10 ./build/ClickerRemoteReceiver-1.10.dmg --title 'Clicker v1.10' --notes 'Release notes' # Trigger homebrew-tap update (auto-calculates SHA256) just update-tap @@ -115,6 +115,9 @@ The `update-tap` command triggers a GitHub Action in `douinc/homebrew-tap` that: - Bundle ID: `com.dou.clicker-ios.watchkitapp` - Double-tap gesture via `handGestureShortcut(.primaryAction)` on watchOS 11+ (Apple Watch Series 9+ / Ultra 2) for next slide - Note: Double-tap requires active display (wrist raised); does not work in always-on / luminance-reduced state +- Wrist flick mode uses CoreMotion gyroscope (`rotationRate.x`) to detect forward/backward wrist flicks for next/previous slide +- "No Going Back" toggle (`gestureNoGoingBack` in UserDefaults) disables backward flick gestures for forward-only navigation, reducing accidental triggers during presentations +- Flick mode settings: gesture lock (3s cooldown), invert gestures, auto-toggle with wrist raise, no going back - Extended WatchKit runtime session (`WKExtendedRuntimeSession` with `self-care` background mode) keeps app active during presentations ## Development Team diff --git a/Casks/clicker-remote-receiver.rb b/Casks/clicker-remote-receiver.rb index 5810101..c15fbbc 100644 --- a/Casks/clicker-remote-receiver.rb +++ b/Casks/clicker-remote-receiver.rb @@ -1,5 +1,5 @@ cask("clicker-remote-receiver") do - version("1.8") + version("1.10") sha256("c93cc1b685318f5fa61d7bfb8f7e5a7896c6400e22268b7097535b5bf8ab3dc3") url("https://github.com/douinc/clicker/releases/download/v#{version}/ClickerRemoteReceiver-#{version}.dmg") diff --git a/LiveActivityWidget/Info.plist b/LiveActivityWidget/Info.plist index 3482a4f..497d44d 100644 --- a/LiveActivityWidget/Info.plist +++ b/LiveActivityWidget/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.8 + 1.10 CFBundleVersion 1 NSExtension diff --git a/MacApp/Info.plist b/MacApp/Info.plist index efb8b96..b214519 100644 --- a/MacApp/Info.plist +++ b/MacApp/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.8 + 1.10 CFBundleVersion 1 LSUIElement diff --git a/README.md b/README.md index 9ee74a0..75d1ae4 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ Download **ClickerRemote** from the [App Store](https://apps.apple.com/us/app/cl | **Visual Progress** | Color-coded timer bar (green → yellow → orange → red) | | **Duration Presets** | 5, 10, 15, 20, 30 minutes or unlimited | | **Haptic Feedback** | Vibrate every 30s, 1m, 2m, or 5m | +| **Wrist Flick Navigation** | Flick your wrist to navigate slides hands-free, with optional "No Going Back" mode for forward-only control | | **Double-Tap Gesture** | Hands-free next slide on Apple Watch Series 9+ / Ultra 2 (watchOS 11+) | | **Stays Active** | Extended runtime session keeps Watch app visible during presentations | diff --git a/WatchApp/ClickerWatchApp.swift b/WatchApp/ClickerWatchApp.swift index 1addaf0..0cdfdf2 100644 --- a/WatchApp/ClickerWatchApp.swift +++ b/WatchApp/ClickerWatchApp.swift @@ -4,21 +4,34 @@ import SwiftUI struct ClickerWatchApp: App { @StateObject private var connectionManager = WatchConnectionManager() @StateObject private var sessionManager = ExtendedSessionManager() + @StateObject private var gestureManager = GestureManager() + @AppStorage("gestureMode") private var gestureMode = "doubleTap" @Environment(\.scenePhase) var scenePhase + private var isFlickMode: Bool { gestureMode == "flickWrist" } + var body: some Scene { WindowGroup { ContentView() .environmentObject(connectionManager) .environmentObject(sessionManager) + .environmentObject(gestureManager) .onAppear { sessionManager.start() + wireGestureCallbacks() } .onChange(of: scenePhase) { _, phase in switch phase { case .active: sessionManager.start() - case .inactive, .background: + if isFlickMode && gestureManager.autoToggleWithWrist { + gestureManager.start() + } + case .inactive: + if isFlickMode && gestureManager.autoToggleWithWrist { + gestureManager.stop() + } + case .background: break @unknown default: break @@ -26,4 +39,13 @@ struct ClickerWatchApp: App { } } } + + private func wireGestureCallbacks() { + gestureManager.onNextSlide = { [weak connectionManager] in + connectionManager?.nextSlide() + } + gestureManager.onPreviousSlide = { [weak connectionManager] in + connectionManager?.previousSlide() + } + } } diff --git a/WatchApp/ContentView.swift b/WatchApp/ContentView.swift index aeedce6..d7a2225 100644 --- a/WatchApp/ContentView.swift +++ b/WatchApp/ContentView.swift @@ -3,7 +3,9 @@ import WatchKit struct ContentView: View { @EnvironmentObject var connectionManager: WatchConnectionManager + @EnvironmentObject var gestureManager: GestureManager @Environment(\.isLuminanceReduced) var isLuminanceReduced + @AppStorage("gestureMode") private var gestureMode = "doubleTap" @State private var timerStartDate: Date? @State private var accumulatedTime: TimeInterval = 0 @State private var timerRunning = false @@ -12,6 +14,8 @@ struct ContentView: View { @State private var lastCrownDetent: Int = 0 @AppStorage("invertCrown") private var invertCrown = false + private var isFlickMode: Bool { gestureMode == "flickWrist" } + private func elapsedTime(at date: Date) -> TimeInterval { if timerRunning, let start = timerStartDate { return accumulatedTime + date.timeIntervalSince(start) @@ -29,85 +33,11 @@ struct ContentView: View { var body: some View { TimelineView(.periodic(from: .now, by: 1.0)) { context in GeometryReader { geometry in - VStack(spacing: 2) { - Button(action: { - WKInterfaceDevice.current().play(.directionUp) - connectionManager.nextSlide() - }) { - Image(systemName: "chevron.right") - .font(.system(size: 32, weight: .bold)) - .frame(width: geometry.size.width * 0.45, height: geometry.size.width * 0.45) - .background(Color.blue.opacity(isLuminanceReduced ? 0.15 : 0.35)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .modifier(PrimaryGestureShortcut()) - - Spacer() - - // Bottom row: Previous, Timer, Settings - HStack(spacing: 8) { - // Previous slide — small circular button - Button(action: { - WKInterfaceDevice.current().play(.directionDown) - connectionManager.previousSlide() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 16, weight: .bold)) - .frame(width: 36, height: 36) - .background(Color.blue.opacity(isLuminanceReduced ? 0.1 : 0.25)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - - Spacer() - - // Timer — tap to start/stop, long press to reset - VStack(spacing: 2) { - Text(formattedTime(at: context.date)) - .font(.system(size: 20, weight: .medium, design: .monospaced)) - .foregroundColor(timerRunning ? .green : .white) - - HStack(spacing: 4) { - Circle() - .fill(connectionManager.isConnectedToMac ? Color.green : Color.red) - .frame(width: 6, height: 6) - Text(connectionManager.isConnectedToMac ? "Connected" : "Disconnected") - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - } - .opacity(isLuminanceReduced ? 0.6 : 1.0) - .onTapGesture { - toggleTimer() - } - .onLongPressGesture { - resetTimer() - } - - Spacer() - - // Settings button - Button { - showSettings = true - } label: { - Image(systemName: "gearshape.fill") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.secondary) - .frame(width: 36, height: 36) - .background(Color.white.opacity(0.08)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - } - .sheet(isPresented: $showSettings) { - NavigationStack { - SettingsView() - } - } + if isFlickMode { + flickModeLayout(geometry: geometry, date: context.date) + } else { + doubleTapModeLayout(geometry: geometry, date: context.date) } - .padding(.horizontal, 4) - .padding(.vertical, 4) } } .focusable() @@ -132,6 +62,223 @@ struct ContentView: View { } } + // MARK: - Double Tap Mode Layout + + @ViewBuilder + private func doubleTapModeLayout(geometry: GeometryProxy, date: Date) -> some View { + VStack(spacing: 2) { + Button(action: { + WKInterfaceDevice.current().play(.directionUp) + connectionManager.nextSlide() + }) { + Image(systemName: "chevron.right") + .font(.system(size: 32, weight: .bold)) + .frame(width: geometry.size.width * 0.45, height: geometry.size.width * 0.45) + .background(Color.blue.opacity(isLuminanceReduced ? 0.15 : 0.35)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .modifier(PrimaryGestureShortcut()) + + Spacer() + + // Bottom row: Previous, Timer, Settings + HStack(spacing: 8) { + // Previous slide — small circular button + Button(action: { + WKInterfaceDevice.current().play(.directionDown) + connectionManager.previousSlide() + }) { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .bold)) + .frame(width: 36, height: 36) + .background(Color.blue.opacity(isLuminanceReduced ? 0.1 : 0.25)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + + Spacer() + + timerView(date: date) + + Spacer() + + settingsButton + } + .sheet(isPresented: $showSettings) { + NavigationStack { + SettingsView() + .environmentObject(gestureManager) + } + } + } + .padding(.horizontal, 4) + .padding(.vertical, 4) + } + + // MARK: - Flick Mode Layout + + @ViewBuilder + private func flickModeLayout(geometry: GeometryProxy, date: Date) -> some View { + VStack(spacing: 6) { + // Previous slide button + Button(action: { + WKInterfaceDevice.current().play(.directionDown) + connectionManager.previousSlide() + }) { + ZStack { + Image(systemName: "chevron.left") + .font(.system(size: 32, weight: .bold)) + + if gestureManager.lastGesture == .previous { + Image(systemName: gestureManager.isLocked ? "lock.fill" : "hand.wave.fill") + .font(.system(size: 16)) + .foregroundColor(.yellow) + .opacity(gestureManager.gestureLockEnabled ? gestureManager.lockProgress : 1.0) + .offset(x: 40, y: -10) + .transition(.opacity) + } + } + .frame(maxWidth: .infinity) + .frame(height: geometry.size.height * 0.30) + .background( + gestureManager.lastGesture == .previous + ? Color.yellow.opacity(gestureManager.gestureLockEnabled ? 0.3 * gestureManager.lockProgress : 0.3) + : Color.blue.opacity(isLuminanceReduced ? 0.1 : 0.3) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .animation(.easeOut(duration: 0.2), value: gestureManager.lastGesture) + .animation(.linear(duration: 0.05), value: gestureManager.lockProgress) + } + .buttonStyle(.plain) + .disabled(gestureManager.noGoingBack) + .opacity(gestureManager.noGoingBack ? 0.3 : 1.0) + + // Timer + gesture toggle + settings row + HStack(spacing: 4) { + // Gesture toggle — tap or hardware double-tap to toggle + Button { + gestureManager.toggle() + WKInterfaceDevice.current().play(gestureManager.isEnabled ? .stop : .start) + } label: { + ZStack { + Image(systemName: gestureManager.isEnabled ? "hand.wave.fill" : "hand.wave") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(gestureManager.isEnabled ? .yellow : .secondary) + + if gestureManager.isLocked { + Image(systemName: "lock.fill") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(.orange) + .offset(x: 8, y: -8) + } + } + .frame(width: 30, height: 30) + .background( + gestureManager.isLocked + ? Color.orange.opacity(0.2 * gestureManager.lockProgress) + : gestureManager.isEnabled + ? Color.yellow.opacity(0.15) + : Color.white.opacity(0.08) + ) + .clipShape(Circle()) + .animation(.easeOut(duration: 0.2), value: gestureManager.isEnabled) + .animation(.linear(duration: 0.05), value: gestureManager.lockProgress) + } + .buttonStyle(.plain) + .modifier(PrimaryGestureShortcut()) + + Spacer() + + timerView(date: date) + + Spacer() + + settingsButton + } + .sheet(isPresented: $showSettings) { + NavigationStack { + SettingsView() + .environmentObject(gestureManager) + } + } + + // Next slide button + Button(action: { + WKInterfaceDevice.current().play(.directionUp) + connectionManager.nextSlide() + }) { + ZStack { + Image(systemName: "chevron.right") + .font(.system(size: 32, weight: .bold)) + + if gestureManager.lastGesture == .next { + Image(systemName: gestureManager.isLocked ? "lock.fill" : "hand.wave.fill") + .font(.system(size: 16)) + .foregroundColor(.yellow) + .opacity(gestureManager.gestureLockEnabled ? gestureManager.lockProgress : 1.0) + .offset(x: 40, y: -10) + .transition(.opacity) + } + } + .frame(maxWidth: .infinity) + .frame(height: geometry.size.height * 0.30) + .background( + gestureManager.lastGesture == .next + ? Color.yellow.opacity(gestureManager.gestureLockEnabled ? 0.3 * gestureManager.lockProgress : 0.3) + : Color.blue.opacity(isLuminanceReduced ? 0.1 : 0.3) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .animation(.easeOut(duration: 0.2), value: gestureManager.lastGesture) + .animation(.linear(duration: 0.05), value: gestureManager.lockProgress) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 4) + .padding(.vertical, 4) + } + + // MARK: - Shared Components + + @ViewBuilder + private func timerView(date: Date) -> some View { + VStack(spacing: 2) { + Text(formattedTime(at: date)) + .font(.system(size: 20, weight: .medium, design: .monospaced)) + .foregroundColor(timerRunning ? .green : .white) + + HStack(spacing: 4) { + Circle() + .fill(connectionManager.isConnectedToMac ? Color.green : Color.red) + .frame(width: 6, height: 6) + Text(connectionManager.isConnectedToMac ? "Connected" : "Disconnected") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + .opacity(isLuminanceReduced ? 0.6 : 1.0) + .onTapGesture { + toggleTimer() + } + .onLongPressGesture { + resetTimer() + } + } + + private var settingsButton: some View { + Button { + showSettings = true + } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: isFlickMode ? 30 : 36, height: isFlickMode ? 30 : 36) + .background(Color.white.opacity(0.08)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + // MARK: - Timer private func resetTimer() { @@ -171,4 +318,5 @@ private struct PrimaryGestureShortcut: ViewModifier { #Preview { ContentView() .environmentObject(WatchConnectionManager()) + .environmentObject(GestureManager()) } diff --git a/WatchApp/GestureManager.swift b/WatchApp/GestureManager.swift new file mode 100644 index 0000000..b88242e --- /dev/null +++ b/WatchApp/GestureManager.swift @@ -0,0 +1,213 @@ +import Foundation +import CoreMotion +import WatchKit +import Combine + +/// Detects wrist flick gestures using CoreMotion for hands-free slide control. +/// +/// Default gesture mapping: +/// - Flick wrist forward (clockwise, away from body) → Next slide +/// - Flick wrist backward (counterclockwise, toward body) → Previous slide +/// +/// Inverted gesture mapping: +/// - Counterclockwise → Next slide +/// - Clockwise → Previous slide +/// +/// Uses the gyroscope rotation rate around the x-axis, which corresponds +/// to wrist flexion/extension — the natural "flick forward" and "pull back" motion. +class GestureManager: ObservableObject { + + // MARK: - Published State + + @Published var isEnabled = false + @Published var lastGesture: DetectedGesture? + @Published var isInverted: Bool { + didSet { UserDefaults.standard.set(isInverted, forKey: "gestureInverted") } + } + @Published var autoToggleWithWrist: Bool { + didSet { UserDefaults.standard.set(autoToggleWithWrist, forKey: "gestureAutoToggle") } + } + @Published var gestureLockEnabled: Bool { + didSet { UserDefaults.standard.set(gestureLockEnabled, forKey: "gestureLockEnabled") } + } + @Published var noGoingBack: Bool { + didSet { UserDefaults.standard.set(noGoingBack, forKey: "gestureNoGoingBack") } + } + @Published var isLocked: Bool = false + @Published var lockProgress: CGFloat = 0.0 + + enum DetectedGesture: Equatable { + case next + case previous + } + + // MARK: - Callbacks + + var onNextSlide: (() -> Void)? + var onPreviousSlide: (() -> Void)? + + // MARK: - Configuration + + /// Minimum rotation rate (rad/s) to trigger a gesture. + /// Higher = less sensitive, fewer false positives. + private let rotationThreshold: Double = 3.0 + + /// Minimum time between gesture triggers to prevent double-fires. + private let cooldownInterval: TimeInterval = 0.8 + + /// Duration of the gesture lock period. + private let lockDuration: TimeInterval = 3.0 + + /// Motion update frequency in Hz. + private let updateFrequency: Double = 50.0 + + // MARK: - Private State + + private let motionManager = CMMotionManager() + private var lastTriggerTime: Date = .distantPast + private let motionQueue = OperationQueue() + private var lockTimer: Timer? + + // MARK: - Initialization + + init() { + self.isInverted = UserDefaults.standard.bool(forKey: "gestureInverted") + self.autoToggleWithWrist = UserDefaults.standard.bool(forKey: "gestureAutoToggle") + self.gestureLockEnabled = UserDefaults.standard.bool(forKey: "gestureLockEnabled") + self.noGoingBack = UserDefaults.standard.bool(forKey: "gestureNoGoingBack") + motionQueue.name = "com.dou.clicker.gesture" + motionQueue.maxConcurrentOperationCount = 1 + } + + // MARK: - Start / Stop + + func start() { + guard motionManager.isDeviceMotionAvailable else { + print("⌚ Device motion not available") + return + } + guard !motionManager.isDeviceMotionActive else { return } + + motionManager.deviceMotionUpdateInterval = 1.0 / updateFrequency + + motionManager.startDeviceMotionUpdates(to: motionQueue) { [weak self] motion, error in + guard let self, let motion else { + if let error { + print("⌚ Motion error: \(error.localizedDescription)") + } + return + } + self.processMotion(motion) + } + + DispatchQueue.main.async { + self.isEnabled = true + } + print("⌚ Gesture detection started") + } + + func stop() { + motionManager.stopDeviceMotionUpdates() + DispatchQueue.main.async { + self.isEnabled = false + self.lastGesture = nil + self.lockTimer?.invalidate() + self.lockTimer = nil + self.isLocked = false + self.lockProgress = 0 + } + print("⌚ Gesture detection stopped") + } + + func toggle() { + if isEnabled { + stop() + } else { + start() + } + } + + // MARK: - Motion Processing + + private func processMotion(_ motion: CMDeviceMotion) { + // Use rotation rate around x-axis: wrist flexion (forward flick) / extension (backward flick) + let rotationX = motion.rotationRate.x + + guard abs(rotationX) > rotationThreshold else { return } + + let now = Date() + // Determine gesture direction before cooldown check so we can skip + // backward gestures without consuming the cooldown window. + let isInverted = self.isInverted + let gesture: DetectedGesture = (rotationX > 0) != isInverted ? .next : .previous + + // Skip previous-slide gestures when "No Going Back" is enabled + if gesture == .previous && self.noGoingBack { return } + + let effectiveCooldown = gestureLockEnabled ? lockDuration : cooldownInterval + guard now.timeIntervalSince(lastTriggerTime) >= effectiveCooldown else { return } + lastTriggerTime = now + + let lockEnabled = self.gestureLockEnabled + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.lastGesture = gesture + + // Play strong directional haptic so the user clearly feels the gesture was recognized + let device = WKInterfaceDevice.current() + switch gesture { + case .next: + device.play(.directionUp) + // Follow up with a second tap after a short delay for emphasis + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + device.play(.directionUp) + } + self.onNextSlide?() + case .previous: + device.play(.directionDown) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + device.play(.directionDown) + } + self.onPreviousSlide?() + } + + if lockEnabled { + self.startLockCountdown(for: gesture) + } else { + // Clear visual indicator after a short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if self.lastGesture == gesture { + self.lastGesture = nil + } + } + } + } + } + + // MARK: - Gesture Lock + + private func startLockCountdown(for gesture: DetectedGesture) { + lockTimer?.invalidate() + isLocked = true + lockProgress = 1.0 + + let startTime = Date() + lockTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] timer in + guard let self else { + timer.invalidate() + return + } + let elapsed = Date().timeIntervalSince(startTime) + if elapsed >= self.lockDuration { + timer.invalidate() + self.lockTimer = nil + self.isLocked = false + self.lockProgress = 0 + self.lastGesture = nil + } else { + self.lockProgress = CGFloat(1.0 - elapsed / self.lockDuration) + } + } + } +} diff --git a/WatchApp/Info.plist b/WatchApp/Info.plist index 6a27c0e..85f93f1 100644 --- a/WatchApp/Info.plist +++ b/WatchApp/Info.plist @@ -15,9 +15,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + 1.9 CFBundleVersion 1 + NSMotionUsageDescription + Clicker uses motion sensors to detect wrist gestures for hands-free slide control during presentations. WKApplication WKBackgroundModes diff --git a/WatchApp/SettingsView.swift b/WatchApp/SettingsView.swift index 81f6953..ea12e61 100644 --- a/WatchApp/SettingsView.swift +++ b/WatchApp/SettingsView.swift @@ -1,32 +1,84 @@ import SwiftUI struct SettingsView: View { + @EnvironmentObject var gestureManager: GestureManager + @AppStorage("gestureMode") private var gestureMode = "doubleTap" @AppStorage("invertCrown") private var invertCrown = false var body: some View { List { Section { - Toggle(isOn: $invertCrown) { - Label("Invert Crown", systemImage: "digitalcrown.horizontal.arrow.counterclockwise") - .font(.system(size: 15)) + Picker("Gesture Mode", selection: $gestureMode) { + Label("Double Tap", systemImage: "hand.tap") + .tag("doubleTap") + Label("Wrist Flick", systemImage: "hand.wave") + .tag("flickWrist") } } footer: { - Text(invertCrown - ? "Crown clockwise = previous slide, counterclockwise = next slide." - : "Crown clockwise = next slide, counterclockwise = previous slide.") + Text(gestureMode == "doubleTap" + ? "Double-tap gesture triggers next slide (watchOS 11+, Series 9+)." + : "Flick your wrist to navigate slides hands-free.") .font(.system(size: 11)) .foregroundColor(.secondary) } - Section { - HStack { - Image(systemName: "info.circle") + if gestureMode == "flickWrist" { + Section { + Toggle(isOn: $gestureManager.gestureLockEnabled) { + Text("Gesture Lock") + .font(.system(size: 15)) + } + } footer: { + Text("After a gesture, lock out further gestures for 3 seconds to prevent accidental triggers.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + + Section { + Toggle(isOn: $gestureManager.noGoingBack) { + Text("No Going Back") + .font(.system(size: 15)) + } + } footer: { + Text("Disable previous slide gesture. Only forward flicks will be recognized.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + + Section { + Toggle(isOn: $gestureManager.isInverted) { + Text("Invert Gestures") + .font(.system(size: 15)) + } + } footer: { + Text(gestureManager.isInverted + ? "Counterclockwise → Next\nClockwise → Previous" + : "Clockwise → Next\nCounterclockwise → Previous") + .font(.system(size: 11)) .foregroundColor(.secondary) - Text("Clicker Remote") + } + + Section { + Toggle(isOn: $gestureManager.autoToggleWithWrist) { + Text("Auto-toggle with Wrist") + .font(.system(size: 15)) + } + } footer: { + Text("Gestures enable on wrist raise and disable on wrist lower.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + + Section { + Toggle(isOn: $invertCrown) { + Label("Invert Crown", systemImage: "digitalcrown.horizontal.arrow.counterclockwise") .font(.system(size: 15)) } } footer: { - Text("Use the large button, Digital Crown, or double-tap gesture (watchOS 11+) to advance slides.") + Text(invertCrown + ? "Crown clockwise = previous slide, counterclockwise = next slide." + : "Crown clockwise = next slide, counterclockwise = previous slide.") .font(.system(size: 11)) .foregroundColor(.secondary) } @@ -38,5 +90,6 @@ struct SettingsView: View { #Preview { NavigationStack { SettingsView() + .environmentObject(GestureManager()) } } diff --git a/iPhoneApp/Info.plist b/iPhoneApp/Info.plist index d7ac169..6274e46 100644 --- a/iPhoneApp/Info.plist +++ b/iPhoneApp/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.8 + 1.10 CFBundleVersion 1 NSBonjourServices diff --git a/index.html b/index.html index dba0f9c..ab2d721 100644 --- a/index.html +++ b/index.html @@ -147,9 +147,9 @@

Presentation Remote for Apple Fans

Install for Mac
-
Mac 1.2ClickerRemoteReceiver
-
iOS 1.8ClickerRemote
-
watchOS 1.8ClickerWatch
+
Mac 1.10ClickerRemoteReceiver
+
iOS 1.10ClickerRemote
+
watchOS 1.10ClickerWatch
diff --git a/project.yml b/project.yml index e3f84af..494d06a 100644 --- a/project.yml +++ b/project.yml @@ -47,7 +47,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.dou.clicker-mac PRODUCT_NAME: ClickerRemoteReceiver MACOSX_DEPLOYMENT_TARGET: "14.0" - MARKETING_VERSION: "1.8" + MARKETING_VERSION: "1.10" CURRENT_PROJECT_VERSION: "1" CODE_SIGN_STYLE: Automatic CODE_SIGN_ENTITLEMENTS: MacApp/ClickerMac.entitlements @@ -95,7 +95,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.dou.clicker-ios PRODUCT_NAME: ClickerRemote IPHONEOS_DEPLOYMENT_TARGET: "18.0" - MARKETING_VERSION: "1.8" + MARKETING_VERSION: "1.10" CURRENT_PROJECT_VERSION: "1" CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: HD35YQ72U4 @@ -128,7 +128,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.dou.clicker-ios.LiveActivity PRODUCT_NAME: ClickerLiveActivity IPHONEOS_DEPLOYMENT_TARGET: "18.0" - MARKETING_VERSION: "1.8" + MARKETING_VERSION: "1.10" CURRENT_PROJECT_VERSION: "1" CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: HD35YQ72U4 @@ -154,15 +154,18 @@ targets: WKCompanionAppBundleIdentifier: com.dou.clicker-ios WKBackgroundModes: - self-care + NSMotionUsageDescription: "Clicker uses motion sensors to detect wrist gestures for hands-free slide control during presentations." entitlements: path: WatchApp/ClickerWatch.entitlements properties: {} + dependencies: + - sdk: CoreMotion.framework settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.dou.clicker-ios.watchkitapp PRODUCT_NAME: ClickerWatch WATCHOS_DEPLOYMENT_TARGET: "10.0" - MARKETING_VERSION: "1.8" + MARKETING_VERSION: "1.10" CURRENT_PROJECT_VERSION: "1" CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: HD35YQ72U4 diff --git a/wiki/API-Reference.md b/wiki/API-Reference.md index 2263294..13d059c 100644 --- a/wiki/API-Reference.md +++ b/wiki/API-Reference.md @@ -240,12 +240,44 @@ class WatchConnectionManager: NSObject, ObservableObject { **Retry Logic**: Commands are retried up to 3 times at 0.5s intervals if the iPhone is temporarily unreachable. +### GestureManager + +Detects wrist flick gestures using CoreMotion for hands-free slide control. + +```swift +class GestureManager: ObservableObject { + @Published var isEnabled: Bool + @Published var lastGesture: DetectedGesture? + @Published var isInverted: Bool + @Published var autoToggleWithWrist: Bool + @Published var gestureLockEnabled: Bool + @Published var noGoingBack: Bool + @Published var isLocked: Bool + @Published var lockProgress: CGFloat + + func start() + func stop() + func toggle() +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `isEnabled` | `Bool` | Whether gesture detection is active | +| `lastGesture` | `DetectedGesture?` | Most recently detected gesture (`.next` or `.previous`) | +| `isInverted` | `Bool` | Swap forward/backward flick directions | +| `autoToggleWithWrist` | `Bool` | Auto-enable on wrist raise, disable on wrist lower | +| `gestureLockEnabled` | `Bool` | Enable 3-second cooldown after each gesture | +| `noGoingBack` | `Bool` | Ignore backward flick gestures (forward-only navigation) | +| `isLocked` | `Bool` | Currently in lock cooldown period | +| `lockProgress` | `CGFloat` | Lock countdown progress (1.0 → 0.0) | + ### Watch SwiftUI Views | View | Description | |------|-------------| -| `ContentView` | Next/previous buttons + timer display + double-tap gesture support | -| `SettingsView` | Watch app settings | +| `ContentView` | Next/previous buttons + timer display + double-tap gesture support; in flick mode, previous button is disabled when "No Going Back" is on | +| `SettingsView` | Watch app settings (gesture mode, gesture lock, no going back, invert gestures, auto-toggle, invert crown) | **Timer Gestures**: - **Tap**: Start/stop timer diff --git a/wiki/Architecture.md b/wiki/Architecture.md index 73866f2..6dc51d0 100644 --- a/wiki/Architecture.md +++ b/wiki/Architecture.md @@ -188,6 +188,32 @@ session.sendMessage(["command": "next"], replyHandler: { reply in The iPhone relays commands to the Mac and sends connection status back to the Watch via `updateApplicationContext`. +### Gesture Detection (Flick Mode) + +The Watch uses CoreMotion's gyroscope to detect wrist flick gestures: + +```swift +// GestureManager.swift — processMotion() +let rotationX = motion.rotationRate.x // Wrist flexion/extension axis +guard abs(rotationX) > rotationThreshold else { return } // 3.0 rad/s threshold + +let gesture: DetectedGesture = (rotationX > 0) != isInverted ? .next : .previous + +// "No Going Back" — skip backward gestures entirely +if gesture == .previous && self.noGoingBack { return } +``` + +**Settings** (persisted via UserDefaults): + +| Setting | Key | Default | Description | +|---------|-----|---------|-------------| +| Gesture Lock | `gestureLockEnabled` | `false` | 3-second cooldown after each gesture | +| Invert Gestures | `gestureInverted` | `false` | Swap forward/backward flick directions | +| Auto-toggle with Wrist | `gestureAutoToggle` | `false` | Enable on wrist raise, disable on wrist lower | +| No Going Back | `gestureNoGoingBack` | `false` | Ignore backward flick gestures (forward-only) | + +When "No Going Back" is enabled, the previous-slide button in the UI is also disabled and dimmed to provide visual feedback. + ### Always-On Display When connected, the iPhone disables the idle timer to prevent the screen from locking: diff --git a/wiki/Feature-Ideas.md b/wiki/Feature-Ideas.md index 88c667c..10173ce 100644 --- a/wiki/Feature-Ideas.md +++ b/wiki/Feature-Ideas.md @@ -140,6 +140,9 @@ A widget in the watchOS Smart Stack showing timer and connection status at a gla ### Digital Crown Control 🟡 Use the Digital Crown rotation to scroll through slides — rotate forward for next, backward for previous. Provides a tactile, precise alternative to wrist gestures or button taps. Configurable sensitivity and detent mapping. +### ~~Forward-Only Flick Mode~~ ✅ Implemented +~~Disable backward flick gestures so only forward flicks are recognized, reducing accidental triggers during presentations.~~ Shipped as the "No Going Back" toggle in Watch flick mode settings. + ### Expanded Double-Tap Gestures 🟢 Build on the existing watchOS 11 `handGestureShortcut` support. Map double-tap to configurable actions: next slide, previous slide, or start/stop timer. Let users choose what double-tap does in settings. diff --git a/wiki/Home.md b/wiki/Home.md index 53ffa49..e02478c 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -17,9 +17,9 @@ Welcome to the ClickerRemote developer documentation. This wiki contains technic ClickerRemote is a presentation remote system consisting of three apps: -- **ClickerRemote** (iOS v1.8) — Remote control app for iPhone -- **ClickerRemoteReceiver** (macOS v1.8) — Menu bar app that receives commands -- **ClickerWatch** (watchOS v1.8) — Apple Watch companion for wrist-based control +- **ClickerRemote** (iOS v1.10) — Remote control app for iPhone +- **ClickerRemoteReceiver** (macOS v1.10) — Menu bar app that receives commands +- **ClickerWatch** (watchOS v1.10) — Apple Watch companion for wrist-based control ```mermaid %%{init: {'theme': 'dark'}}%% diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index c9ff5b1..bb3a9e6 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -161,6 +161,14 @@ Keys must be in `info.properties`, not just the source Info.plist files. **Solution**: Long press (not tap) the timer display. You should feel a strong haptic pulse confirming the reset. +### Wrist Flick Only Goes Forward / Previous Not Working + +**Check "No Going Back" setting**: Open Watch Settings → ensure "No Going Back" is toggled off. When enabled, backward flick gestures are intentionally ignored and the previous-slide button is dimmed. + +### Wrist Flick Triggers Wrong Direction + +**Check "Invert Gestures" setting**: Open Watch Settings → toggle "Invert Gestures" to swap the forward/backward flick mapping. The footer text shows the current mapping (Clockwise → Next or Counterclockwise → Next). + --- ## SwiftUI Issues