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