From 18c0fe56f240e6857fa4a2a2b322484a9afa5f26 Mon Sep 17 00:00:00 2001 From: donny-son Date: Fri, 27 Mar 2026 18:36:30 +0900 Subject: [PATCH 1/5] bring back wrist flick in ios --- LiveActivityWidget/Info.plist | 2 +- MacApp/Info.plist | 2 +- WatchApp/ClickerWatchApp.swift | 24 ++- WatchApp/ContentView.swift | 302 ++++++++++++++++++++++++--------- WatchApp/GestureManager.swift | 205 ++++++++++++++++++++++ WatchApp/Info.plist | 4 +- WatchApp/SettingsView.swift | 64 +++++-- iPhoneApp/Info.plist | 2 +- project.yml | 11 +- 9 files changed, 518 insertions(+), 98 deletions(-) create mode 100644 WatchApp/GestureManager.swift diff --git a/LiveActivityWidget/Info.plist b/LiveActivityWidget/Info.plist index 3482a4f..973ede9 100644 --- a/LiveActivityWidget/Info.plist +++ b/LiveActivityWidget/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.8 + 1.9 CFBundleVersion 1 NSExtension diff --git a/MacApp/Info.plist b/MacApp/Info.plist index efb8b96..8f65b85 100644 --- a/MacApp/Info.plist +++ b/MacApp/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.8 + 1.9 CFBundleVersion 1 LSUIElement 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..4358e05 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,221 @@ 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) + + // 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 +316,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..4af5cc0 --- /dev/null +++ b/WatchApp/GestureManager.swift @@ -0,0 +1,205 @@ +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 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") + 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() + let effectiveCooldown = gestureLockEnabled ? lockDuration : cooldownInterval + guard now.timeIntervalSince(lastTriggerTime) >= effectiveCooldown else { return } + lastTriggerTime = now + + // Positive x rotation = wrist flick forward, Negative = backward + // When inverted: counterclockwise (negative) = next, clockwise (positive) = previous + let isInverted = self.isInverted + let gesture: DetectedGesture = (rotationX > 0) != isInverted ? .next : .previous + 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..ea0fdc2 100644 --- a/WatchApp/SettingsView.swift +++ b/WatchApp/SettingsView.swift @@ -1,32 +1,73 @@ 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.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) + } + + 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) - Text("Clicker Remote") + } + } + + 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 +79,6 @@ struct SettingsView: View { #Preview { NavigationStack { SettingsView() + .environmentObject(GestureManager()) } } diff --git a/iPhoneApp/Info.plist b/iPhoneApp/Info.plist index d7ac169..2575b62 100644 --- a/iPhoneApp/Info.plist +++ b/iPhoneApp/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.8 + 1.9 CFBundleVersion 1 NSBonjourServices diff --git a/project.yml b/project.yml index e3f84af..f98425d 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.9" 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.9" 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.9" 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.9" CURRENT_PROJECT_VERSION: "1" CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: HD35YQ72U4 From 9dee4dca9bee815436b07741592584a687be3435 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 00:47:39 +0000 Subject: [PATCH 2/5] Add "No Going Back" toggle for Apple Watch flick mode When enabled, backward wrist flick gestures are ignored so only forward flicks are recognized. This makes flick navigation more reliable during presentations where going back is rarely needed. The previous-slide button is also visually dimmed and disabled in the UI. https://claude.ai/code/session_01CGvK38r5aSfHsu4JiUmmbv --- WatchApp/ContentView.swift | 2 ++ WatchApp/GestureManager.swift | 16 ++++++++++++---- WatchApp/SettingsView.swift | 11 +++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/WatchApp/ContentView.swift b/WatchApp/ContentView.swift index 4358e05..d7a2225 100644 --- a/WatchApp/ContentView.swift +++ b/WatchApp/ContentView.swift @@ -151,6 +151,8 @@ struct ContentView: View { .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) { diff --git a/WatchApp/GestureManager.swift b/WatchApp/GestureManager.swift index 4af5cc0..b88242e 100644 --- a/WatchApp/GestureManager.swift +++ b/WatchApp/GestureManager.swift @@ -30,6 +30,9 @@ class GestureManager: ObservableObject { @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 @@ -71,6 +74,7 @@ class GestureManager: ObservableObject { 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 } @@ -132,14 +136,18 @@ class GestureManager: ObservableObject { 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 - // Positive x rotation = wrist flick forward, Negative = backward - // When inverted: counterclockwise (negative) = next, clockwise (positive) = previous - let isInverted = self.isInverted - let gesture: DetectedGesture = (rotationX > 0) != isInverted ? .next : .previous let lockEnabled = self.gestureLockEnabled DispatchQueue.main.async { [weak self] in diff --git a/WatchApp/SettingsView.swift b/WatchApp/SettingsView.swift index ea0fdc2..ea12e61 100644 --- a/WatchApp/SettingsView.swift +++ b/WatchApp/SettingsView.swift @@ -34,6 +34,17 @@ struct SettingsView: View { .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") From a9e973fa407b62361a23f707301ecb66f2c59f10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 00:50:35 +0000 Subject: [PATCH 3/5] Document "No Going Back" flick mode toggle across all docs Update README, CLAUDE.md, and wiki (Architecture, API Reference, Troubleshooting, Feature Ideas) with the new forward-only flick navigation setting for Apple Watch. https://claude.ai/code/session_01CGvK38r5aSfHsu4JiUmmbv --- CLAUDE.md | 3 +++ README.md | 1 + wiki/API-Reference.md | 36 ++++++++++++++++++++++++++++++++++-- wiki/Architecture.md | 26 ++++++++++++++++++++++++++ wiki/Feature-Ideas.md | 3 +++ wiki/Troubleshooting.md | 8 ++++++++ 6 files changed, 75 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 08e4531..237092e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/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/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/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 From a1c7da6ceea1fbaf09b69321186de7b3c5425727 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 00:52:53 +0000 Subject: [PATCH 4/5] Bump version to 1.10 across all targets and documentation Update MARKETING_VERSION in project.yml (all 4 targets), Info.plist files (Mac, iOS, LiveActivity), CLAUDE.md, wiki/Home.md, index.html, and Homebrew cask formula. https://claude.ai/code/session_01CGvK38r5aSfHsu4JiUmmbv --- CLAUDE.md | 8 ++++---- Casks/clicker-remote-receiver.rb | 2 +- LiveActivityWidget/Info.plist | 2 +- MacApp/Info.plist | 2 +- iPhoneApp/Info.plist | 2 +- index.html | 4 ++-- project.yml | 8 ++++---- wiki/Home.md | 6 +++--- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 237092e..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 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 973ede9..497d44d 100644 --- a/LiveActivityWidget/Info.plist +++ b/LiveActivityWidget/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.9 + 1.10 CFBundleVersion 1 NSExtension diff --git a/MacApp/Info.plist b/MacApp/Info.plist index 8f65b85..b214519 100644 --- a/MacApp/Info.plist +++ b/MacApp/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.9 + 1.10 CFBundleVersion 1 LSUIElement diff --git a/iPhoneApp/Info.plist b/iPhoneApp/Info.plist index 2575b62..6274e46 100644 --- a/iPhoneApp/Info.plist +++ b/iPhoneApp/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.9 + 1.10 CFBundleVersion 1 NSBonjourServices diff --git a/index.html b/index.html index dba0f9c..2555179 100644 --- a/index.html +++ b/index.html @@ -148,8 +148,8 @@

Presentation Remote for Apple Fans

Mac 1.2ClickerRemoteReceiver
-
iOS 1.8ClickerRemote
-
watchOS 1.8ClickerWatch
+
iOS 1.10ClickerRemote
+
watchOS 1.10ClickerWatch
diff --git a/project.yml b/project.yml index f98425d..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.9" + 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.9" + 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.9" + MARKETING_VERSION: "1.10" CURRENT_PROJECT_VERSION: "1" CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: HD35YQ72U4 @@ -165,7 +165,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.dou.clicker-ios.watchkitapp PRODUCT_NAME: ClickerWatch WATCHOS_DEPLOYMENT_TARGET: "10.0" - MARKETING_VERSION: "1.9" + MARKETING_VERSION: "1.10" CURRENT_PROJECT_VERSION: "1" CODE_SIGN_STYLE: Automatic DEVELOPMENT_TEAM: HD35YQ72U4 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'}}%% From e6e1aeea41025849b2804d58ad176cdd69c63d72 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 00:54:17 +0000 Subject: [PATCH 5/5] Update Mac app version to 1.10 in index.html https://claude.ai/code/session_01CGvK38r5aSfHsu4JiUmmbv --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 2555179..ab2d721 100644 --- a/index.html +++ b/index.html @@ -147,7 +147,7 @@

Presentation Remote for Apple Fans

Install for Mac
-
Mac 1.2ClickerRemoteReceiver
+
Mac 1.10ClickerRemoteReceiver
iOS 1.10ClickerRemote
watchOS 1.10ClickerWatch