?
+
+ private init() {}
+
+ // MARK: - Start / End
+
+ func startActivity(macName: String) {
+ guard ActivityAuthorizationInfo().areActivitiesEnabled else {
+ print("🔴 Live Activities not enabled")
+ return
+ }
+
+ // End any existing activity first
+ endActivity()
+
+ let attributes = PresentationAttributes(macName: macName)
+ let initialState = PresentationAttributes.ContentState(
+ timerStartDate: nil,
+ accumulatedSeconds: 0,
+ isTimerRunning: false,
+ totalDuration: nil
+ )
+
+ do {
+ activity = try Activity.request(
+ attributes: attributes,
+ content: .init(state: initialState, staleDate: nil),
+ pushType: nil
+ )
+ print("🟢 Live Activity started")
+ } catch {
+ print("❌ Failed to start Live Activity: \(error)")
+ }
+ }
+
+ func endActivity() {
+ guard let activity = activity else { return }
+
+ let finalState = PresentationAttributes.ContentState(
+ timerStartDate: nil,
+ accumulatedSeconds: 0,
+ isTimerRunning: false,
+ totalDuration: nil
+ )
+
+ Task {
+ await activity.end(
+ .init(state: finalState, staleDate: nil),
+ dismissalPolicy: .immediate
+ )
+ print("🔴 Live Activity ended")
+ }
+
+ self.activity = nil
+ }
+
+ // MARK: - Timer Updates
+
+ func updateTimerState(
+ startDate: Date?,
+ accumulatedSeconds: Int,
+ isRunning: Bool,
+ totalDuration: Int?
+ ) {
+ guard let activity = activity else { return }
+
+ let state = PresentationAttributes.ContentState(
+ timerStartDate: startDate,
+ accumulatedSeconds: accumulatedSeconds,
+ isTimerRunning: isRunning,
+ totalDuration: totalDuration
+ )
+
+ Task {
+ await activity.update(.init(state: state, staleDate: nil))
+ }
+ }
+}
diff --git a/iPhoneApp/PaywallView.swift b/iPhoneApp/PaywallView.swift
index 5073844..df7dc58 100644
--- a/iPhoneApp/PaywallView.swift
+++ b/iPhoneApp/PaywallView.swift
@@ -12,7 +12,7 @@ struct PaywallView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
- SubscriptionStoreView(groupID: subscriptionGroupID) {
+ SubscriptionStoreView(productIDs: [subscriptionProductID]) {
VStack(spacing: 24) {
// Header
VStack(spacing: 16) {
@@ -45,7 +45,7 @@ struct PaywallView: View {
}
.padding(.top, 40)
}
- .subscriptionStoreButtonLabel(.multiline)
+ .subscriptionStoreButtonLabel(.action)
.storeButton(.visible, for: .restorePurchases)
.onInAppPurchaseCompletion { _, result in
if case .success(.success(_)) = result {
diff --git a/iPhoneApp/PresentationAttributes.swift b/iPhoneApp/PresentationAttributes.swift
new file mode 100644
index 0000000..62a2f4e
--- /dev/null
+++ b/iPhoneApp/PresentationAttributes.swift
@@ -0,0 +1,17 @@
+import ActivityKit
+import Foundation
+
+struct PresentationAttributes: ActivityAttributes {
+ var macName: String
+
+ struct ContentState: Codable, Hashable {
+ /// When the current timer run started (nil if paused/stopped)
+ var timerStartDate: Date?
+ /// Seconds accumulated from previous runs before the current one
+ var accumulatedSeconds: Int
+ /// Whether the timer is currently running
+ var isTimerRunning: Bool
+ /// Total presentation duration in seconds (nil = no limit)
+ var totalDuration: Int?
+ }
+}
diff --git a/iPhoneApp/PresentationTimer.swift b/iPhoneApp/PresentationTimer.swift
index 47f9147..0d3962c 100644
--- a/iPhoneApp/PresentationTimer.swift
+++ b/iPhoneApp/PresentationTimer.swift
@@ -32,7 +32,9 @@ class PresentationTimer: ObservableObject {
// MARK: - Published Properties
@Published var elapsedTime: TimeInterval = 0
@Published var isRunning = false
- @Published var config = TimerConfig(vibrationInterval: 60, totalDuration: nil)
+ @Published var config = TimerConfig(vibrationInterval: 60, totalDuration: nil) {
+ didSet { updateLiveActivity() }
+ }
@Published var lastVibratedAt: TimeInterval = 0
// MARK: - Private Properties
@@ -94,30 +96,49 @@ class PresentationTimer: ObservableObject {
}
// MARK: - Timer Controls
+ /// The date when the current timer run started (used for Live Activity)
+ private var timerStartDate: Date?
+
func start() {
guard !isRunning else { return }
-
+
isRunning = true
lastVibratedAt = elapsedTime
-
+ timerStartDate = Date()
+
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.tick()
}
-
+
// Keep timer running in background
RunLoop.current.add(timer!, forMode: .common)
+ updateLiveActivity()
}
-
+
func pause() {
isRunning = false
+ timerStartDate = nil
timer?.invalidate()
timer = nil
+ updateLiveActivity()
}
-
+
func reset() {
pause()
elapsedTime = 0
lastVibratedAt = 0
+ updateLiveActivity()
+ }
+
+ // MARK: - Live Activity
+
+ private func updateLiveActivity() {
+ LiveActivityManager.shared.updateTimerState(
+ startDate: timerStartDate,
+ accumulatedSeconds: Int(elapsedTime),
+ isRunning: isRunning,
+ totalDuration: config.totalDuration.map { Int($0) }
+ )
}
func toggle() {
diff --git a/iPhoneApp/SubscriptionManager.swift b/iPhoneApp/SubscriptionManager.swift
index b0e0bce..0b3130c 100644
--- a/iPhoneApp/SubscriptionManager.swift
+++ b/iPhoneApp/SubscriptionManager.swift
@@ -4,9 +4,6 @@ import Foundation
/// Product ID for the yearly subscription
let subscriptionProductID = "com.dou.clicker_ios.subscription.yearly"
-/// Subscription group ID for StoreKit configuration
-let subscriptionGroupID = "21901349"
-
/// Manages subscription state, purchases, and trial tracking using StoreKit 2
@MainActor
@Observable
diff --git a/iPhoneApp/iPhoneConnectionManager.swift b/iPhoneApp/iPhoneConnectionManager.swift
index 0e50991..c410d70 100644
--- a/iPhoneApp/iPhoneConnectionManager.swift
+++ b/iPhoneApp/iPhoneConnectionManager.swift
@@ -246,6 +246,7 @@ class iPhoneConnectionManager: NSObject, ObservableObject {
connectedMac = nil
isConnected = false
statusMessage = "Disconnected"
+ LiveActivityManager.shared.endActivity()
startBrowsing()
}
@@ -296,6 +297,7 @@ extension iPhoneConnectionManager: MCSessionDelegate {
self.updateWatchWithConnectionStatus()
self.isSearching = false
self.lastError = nil
+ LiveActivityManager.shared.startActivity(macName: peerID.displayName)
case .connecting:
self.debugLog("SESSION STATE: Connecting to \(peerID.displayName)", level: .network)
@@ -310,6 +312,7 @@ extension iPhoneConnectionManager: MCSessionDelegate {
self.isConnected = false
self.stopKeepalive()
self.updateWatchWithConnectionStatus()
+ LiveActivityManager.shared.endActivity()
if self.lastConnectedMacName != nil {
self.statusMessage = "Connection lost, reconnecting..."
self.debugLog("Scheduling reconnect attempt...", level: .info)
diff --git a/index.html b/index.html
index 51cfb5f..dba0f9c 100644
--- a/index.html
+++ b/index.html
@@ -148,8 +148,8 @@ Presentation Remote for Apple Fans
Mac 1.2ClickerRemoteReceiver
-
iOS 1.6ClickerRemote
-
watchOS 1.6ClickerWatch
+
iOS 1.8ClickerRemote
+
watchOS 1.8ClickerWatch
@@ -175,8 +175,8 @@
Peer-to-peer reliability
02
-
Cross-device gesture flow
-
Apple Watch flick gestures trigger the same RemoteCommand used by the iPhone UI, routed through WatchConnectivity with built-in lockout guards.
+
Cross-device control
+
Apple Watch double-tap gestures and Digital Crown rotation trigger the same RemoteCommand used by the iPhone UI, routed through WatchConnectivity.
03
@@ -205,6 +205,7 @@
ClickerRemote (iPhone)
Dark-only UI for backstage visibility
Subscription-ready with trials
Automatic Mac discovery via MultipeerConnectivity
+ Live Activities on Lock Screen & Dynamic Island
@@ -247,7 +248,7 @@ Pair the iPhone remote
Activate the Watch
-
Enable the Watch companion from the iOS app. Wrist flicks become precise previous/next commands with a 3-second lockout.
+
The Watch companion installs automatically. Use the Digital Crown, double-tap gesture, or on-screen buttons to control slides from your wrist.
Present with confidence
@@ -297,7 +298,7 @@ Security posture
Local only: Commands stay on your network
Accessibility: Minimal permissions, once per Mac
Hardened Runtime: Required for notarization
- Watch lockout: Prevents accidental slide jumps
+ Crown threshold: 3-detent threshold prevents accidental slide jumps
diff --git a/project.yml b/project.yml
index efa927f..e3f84af 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.2"
+ MARKETING_VERSION: "1.8"
CURRENT_PROJECT_VERSION: "1"
CODE_SIGN_STYLE: Automatic
CODE_SIGN_ENTITLEMENTS: MacApp/ClickerMac.entitlements
@@ -88,13 +88,14 @@ targets:
NSBonjourServices:
- _clickerremote._tcp
- _clickerremote._udp
+ NSSupportsLiveActivities: true
NSLocalNetworkUsageDescription: "Clicker needs local network access to find and connect to your Mac."
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.dou.clicker-ios
PRODUCT_NAME: ClickerRemote
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
- MARKETING_VERSION: "1.6"
+ MARKETING_VERSION: "1.8"
CURRENT_PROJECT_VERSION: "1"
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: HD35YQ72U4
@@ -104,6 +105,35 @@ targets:
dependencies:
- target: ClickerWatch
embed: true
+ - target: ClickerLiveActivity
+ embed: true
+
+ # ─────────────────────────────────────────────────────────────
+ # Live Activity Widget Extension
+ # ─────────────────────────────────────────────────────────────
+ ClickerLiveActivity:
+ type: app-extension
+ platform: iOS
+ sources:
+ - path: LiveActivityWidget
+ - path: iPhoneApp/PresentationAttributes.swift
+ info:
+ path: LiveActivityWidget/Info.plist
+ properties:
+ CFBundleDisplayName: ClickerLiveActivity
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.widgetkit-extension
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.dou.clicker-ios.LiveActivity
+ PRODUCT_NAME: ClickerLiveActivity
+ IPHONEOS_DEPLOYMENT_TARGET: "18.0"
+ MARKETING_VERSION: "1.8"
+ CURRENT_PROJECT_VERSION: "1"
+ CODE_SIGN_STYLE: Automatic
+ DEVELOPMENT_TEAM: HD35YQ72U4
+ TARGETED_DEVICE_FAMILY: "1"
+ SWIFT_EMIT_LOC_STRINGS: "YES"
# ─────────────────────────────────────────────────────────────
# Apple Watch App
@@ -124,22 +154,15 @@ 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."
- NSHealthShareUsageDescription: "Clicker does not read your health data. HealthKit is used solely to maintain an active workout session that keeps the app visible during presentations."
- NSHealthUpdateUsageDescription: "Clicker uses HealthKit to maintain an active session during presentations, keeping the app visible for reliable gesture control."
entitlements:
path: WatchApp/ClickerWatch.entitlements
- properties:
- com.apple.developer.healthkit: true
- dependencies:
- - sdk: CoreMotion.framework
- - sdk: HealthKit.framework
+ properties: {}
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.dou.clicker-ios.watchkitapp
PRODUCT_NAME: ClickerWatch
WATCHOS_DEPLOYMENT_TARGET: "10.0"
- MARKETING_VERSION: "1.6"
+ MARKETING_VERSION: "1.8"
CURRENT_PROJECT_VERSION: "1"
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: HD35YQ72U4
@@ -162,6 +185,7 @@ schemes:
targets:
ClickeriOS: all
ClickerWatch: all
+ ClickerLiveActivity: all
run:
config: Debug
executable: ClickeriOS
diff --git a/public/logo/ios-logo.png b/public/logo/ios-logo.png
index a28024f..a4fcd34 100644
Binary files a/public/logo/ios-logo.png and b/public/logo/ios-logo.png differ
diff --git a/public/logo/mac-logo.png b/public/logo/mac-logo.png
index 833ba5f..632eb72 100644
Binary files a/public/logo/mac-logo.png and b/public/logo/mac-logo.png differ
diff --git a/wiki/API-Reference.md b/wiki/API-Reference.md
index acc4446..2263294 100644
--- a/wiki/API-Reference.md
+++ b/wiki/API-Reference.md
@@ -240,32 +240,12 @@ class WatchConnectionManager: NSObject, ObservableObject {
**Retry Logic**: Commands are retried up to 3 times at 0.5s intervals if the iPhone is temporarily unreachable.
-### GestureManager
-
-Handles CoreMotion wrist gesture detection for hands-free slide control.
-
-```swift
-class GestureManager: ObservableObject {
- @Published var isGestureEnabled: Bool
- @Published var gestureLockEnabled: Bool
- @Published var isInverted: Bool
- @Published var autoToggleWithWrist: Bool
-}
-```
-
-| Property | Type | Description |
-|----------|------|-------------|
-| `isGestureEnabled` | `Bool` | Whether gesture detection is active |
-| `gestureLockEnabled` | `Bool` | 3-second lockout after gesture to prevent accidental triggers |
-| `isInverted` | `Bool` | Swap flick direction mapping |
-| `autoToggleWithWrist` | `Bool` | Gestures enable on wrist raise, disable on wrist lower |
-
### Watch SwiftUI Views
| View | Description |
|------|-------------|
-| `ContentView` | Previous/next buttons + timer display + gesture toggle |
-| `SettingsView` | Gesture lock, inversion, and auto-toggle settings |
+| `ContentView` | Next/previous buttons + timer display + double-tap gesture support |
+| `SettingsView` | Watch app settings |
**Timer Gestures**:
- **Tap**: Start/stop timer
diff --git a/wiki/Feature-Ideas.md b/wiki/Feature-Ideas.md
index 429cac9..88c667c 100644
--- a/wiki/Feature-Ideas.md
+++ b/wiki/Feature-Ideas.md
@@ -125,7 +125,7 @@ Let users rearrange, resize, or hide buttons on the iPhone remote. Some presente
Allow light mode for bright environments or custom accent colors beyond the current dark-only design. Per-user preference with quick toggle. Keep dark mode as default since it's optimized for stage visibility.
### Configurable Haptic Patterns 🟡
-Let users create custom vibration patterns for different events: slide advance, timer milestones, gesture lock, connection lost. Different intensity levels and rhythms to convey information without looking at the screen.
+Let users create custom vibration patterns for different events: slide advance, timer milestones, connection lost. Different intensity levels and rhythms to convey information without looking at the screen.
---
@@ -141,7 +141,7 @@ A widget in the watchOS Smart Stack showing timer and connection status at a gla
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.
### Expanded Double-Tap Gestures 🟢
-Build on the existing watchOS 11 `handGestureShortcut` support. Map double-tap to configurable actions: next slide, previous slide, start/stop timer, or toggle gesture lock. Let users choose what double-tap does in settings.
+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 25de41e..53ffa49 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.6) — Remote control app for iPhone
-- **ClickerRemoteReceiver** (macOS v1.2) — Menu bar app that receives commands
-- **ClickerWatch** (watchOS v1.6) — Apple Watch companion for wrist-based control
+- **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
```mermaid
%%{init: {'theme': 'dark'}}%%