diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9850423 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## v1.7 + +- Removed CoreMotion wrist flick gesture detection from Apple Watch app due to unreliable production behavior +- Added double-tap gesture support (`handGestureShortcut(.primaryAction)`) for advancing slides on watchOS 11+ (Apple Watch Series 9+ / Ultra 2) +- Note: Double-tap requires the watch display to be active (wrist raised); it does not work in always-on / luminance-reduced state diff --git a/CLAUDE.md b/CLAUDE.md index 42b579e..08e4531 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.2): Menu bar app that receives commands and sends keystrokes to presentation software -- **iPhone App** (`ClickerRemote` v1.6): Remote control with vertical slide navigation and presentation timer -- **Apple Watch App** (`ClickerWatch` v1.6): Companion watch app with gesture-based slide control using wrist flicks +- **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 ## 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.2 ./build/ClickerRemoteReceiver-1.2.dmg --title 'Clicker v1.2' --notes 'Release notes' +gh release create v1.8 ./build/ClickerRemoteReceiver-1.8.dmg --title 'Clicker v1.8' --notes 'Release notes' # Trigger homebrew-tap update (auto-calculates SHA256) just update-tap @@ -113,12 +113,9 @@ The `update-tap` command triggers a GitHub Action in `douinc/homebrew-tap` that: ### Apple Watch App Specifics - App name: `ClickerWatch` (embedded in iOS app, distributed via App Store) - Bundle ID: `com.dou.clicker-ios.watchkitapp` -- Gesture detection via CoreMotion (wrist flick rotation rate on x-axis) -- Gesture lock: 3-second lockout after gesture to prevent accidental triggers -- Gesture inversion: option to swap flick direction mapping -- Auto-toggle: gesture activation follows wrist raise/lower -- HealthKit workout session keeps app active during presentations -- Extended WatchKit session for background operation +- 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 +- Extended WatchKit runtime session (`WKExtendedRuntimeSession` with `self-care` background mode) keeps app active during presentations ## Development Team @@ -129,6 +126,7 @@ Team ID: `HD35YQ72U4` (DOU Inc.) 1. Edit Swift source files directly 2. If changing build settings, targets, or Info.plist keys, edit `project.yml` 3. Run `just generate` after modifying `project.yml` to regenerate the Xcode project + - Note that this overrides the version information. ## Useful Debugging Commands diff --git a/Casks/clicker-remote-receiver.rb b/Casks/clicker-remote-receiver.rb index 2b1159a..5810101 100644 --- a/Casks/clicker-remote-receiver.rb +++ b/Casks/clicker-remote-receiver.rb @@ -1,30 +1,34 @@ -cask "clicker-remote-receiver" do - version "1.2" - sha256 "c93cc1b685318f5fa61d7bfb8f7e5a7896c6400e22268b7097535b5bf8ab3dc3" +cask("clicker-remote-receiver") do + version("1.8") + sha256("c93cc1b685318f5fa61d7bfb8f7e5a7896c6400e22268b7097535b5bf8ab3dc3") - url "https://github.com/douinc/clicker/releases/download/v#{version}/ClickerRemoteReceiver-#{version}.dmg" - name "Clicker Remote Receiver" - desc "Presentation remote control - Mac receiver for iOS ClickerRemote app" - homepage "https://github.com/douinc/clicker" + url("https://github.com/douinc/clicker/releases/download/v#{version}/ClickerRemoteReceiver-#{version}.dmg") + name("Clicker Remote Receiver") + desc("Presentation remote control - Mac receiver for iOS ClickerRemote app") + homepage("https://github.com/douinc/clicker") livecheck do - url :url - strategy :github_latest + url(:url) + strategy(:github_latest) end - depends_on macos: ">= :sonoma" + depends_on(macos: ">= :sonoma") - app "ClickerRemoteReceiver.app" + app("ClickerRemoteReceiver.app") postflight do - # Request accessibility permission on first install - system_command "/usr/bin/osascript", - args: ["-e", 'tell application "System Preferences" to reveal anchor "Privacy_Accessibility" of pane id "com.apple.preference.security"'], - sudo: false + # Open Accessibility privacy pane so user can grant permission + system_command( + "/usr/bin/open", + args: ["x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"], + sudo: false + ) end - zap trash: [ - "~/Library/Preferences/com.dou.clicker-mac.plist", - "~/Library/Application Support/ClickerRemoteReceiver", - ] + zap( + trash: [ + "~/Library/Preferences/com.dou.clicker-mac.plist", + "~/Library/Application Support/ClickerRemoteReceiver" + ] + ) end diff --git a/LiveActivityWidget/Info.plist b/LiveActivityWidget/Info.plist new file mode 100644 index 0000000..3482a4f --- /dev/null +++ b/LiveActivityWidget/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ClickerLiveActivity + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.8 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LiveActivityWidget/LiveActivityBundle.swift b/LiveActivityWidget/LiveActivityBundle.swift new file mode 100644 index 0000000..3d1147f --- /dev/null +++ b/LiveActivityWidget/LiveActivityBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct LiveActivityBundle: WidgetBundle { + var body: some Widget { + PresentationLiveActivity() + } +} diff --git a/LiveActivityWidget/PresentationLiveActivity.swift b/LiveActivityWidget/PresentationLiveActivity.swift new file mode 100644 index 0000000..8dd98bd --- /dev/null +++ b/LiveActivityWidget/PresentationLiveActivity.swift @@ -0,0 +1,208 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct PresentationLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: PresentationAttributes.self) { context in + // Lock Screen banner + LockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + // Expanded regions + DynamicIslandExpandedRegion(.leading) { + Label(context.attributes.macName, systemImage: "desktopcomputer") + .font(.caption2) + .foregroundStyle(.secondary) + } + + DynamicIslandExpandedRegion(.trailing) { + if let total = context.state.totalDuration { + remainingText(state: context.state, total: total) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + DynamicIslandExpandedRegion(.center) { + timerDisplay(state: context.state) + .font(.system(size: 32, weight: .light, design: .monospaced)) + .foregroundStyle(timerColor(state: context.state)) + } + + DynamicIslandExpandedRegion(.bottom) { + if let progress = progress(state: context.state) { + ProgressView(value: min(progress, 1.0)) + .tint(progressColor(progress)) + } + } + } compactLeading: { + timerDisplay(state: context.state) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(timerColor(state: context.state)) + } compactTrailing: { + Image(systemName: "circle.fill") + .font(.system(size: 6)) + .foregroundStyle(.green) + } minimal: { + Image(systemName: "play.circle.fill") + .foregroundStyle(context.state.isTimerRunning ? .green : .secondary) + } + } + } + + // MARK: - Timer Display + + @ViewBuilder + private func timerDisplay(state: PresentationAttributes.ContentState) -> some View { + if state.isTimerRunning, let startDate = state.timerStartDate { + // Synthetic start = startDate - accumulatedSeconds + // Text(date, style: .timer) counts up from the given date + let syntheticStart = startDate.addingTimeInterval(-Double(state.accumulatedSeconds)) + Text(syntheticStart, style: .timer) + .multilineTextAlignment(.center) + } else { + // Paused: show static time + Text(formatTime(state.accumulatedSeconds)) + .multilineTextAlignment(.center) + } + } + + @ViewBuilder + private func remainingText(state: PresentationAttributes.ContentState, total: Int) -> some View { + if state.isTimerRunning, let startDate = state.timerStartDate { + let endDate = startDate.addingTimeInterval(Double(total - state.accumulatedSeconds)) + Text(endDate, style: .timer) + .multilineTextAlignment(.trailing) + } else { + let remaining = max(0, total - state.accumulatedSeconds) + Text("-\(formatTime(remaining))") + .multilineTextAlignment(.trailing) + } + } + + // MARK: - Helpers + + private func formatTime(_ seconds: Int) -> String { + let m = seconds / 60 + let s = seconds % 60 + return String(format: "%02d:%02d", m, s) + } + + private func timerColor(state: PresentationAttributes.ContentState) -> Color { + guard let total = state.totalDuration, total > 0 else { return .white } + let elapsed = state.accumulatedSeconds + if elapsed >= total { return .red } + if elapsed >= Int(Double(total) * 0.9) { return .orange } + return .white + } + + private func progress(state: PresentationAttributes.ContentState) -> Double? { + guard let total = state.totalDuration, total > 0 else { return nil } + return Double(state.accumulatedSeconds) / Double(total) + } + + private func progressColor(_ progress: Double) -> Color { + if progress >= 1.0 { return .red } + if progress >= 0.9 { return .orange } + if progress >= 0.75 { return .yellow } + return .green + } +} + +// MARK: - Lock Screen View + +private struct LockScreenView: View { + let context: ActivityViewContext + + var body: some View { + VStack(spacing: 8) { + HStack { + HStack(spacing: 6) { + Image(systemName: "circle.fill") + .font(.system(size: 6)) + .foregroundStyle(.green) + Text(context.attributes.macName) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if let total = context.state.totalDuration { + remainingLabel(total: total) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // Timer + timerText + .font(.system(size: 36, weight: .light, design: .monospaced)) + .foregroundStyle(timerColor) + .frame(maxWidth: .infinity, alignment: .center) + + // Progress bar + if let total = context.state.totalDuration, total > 0 { + let progress = min(Double(context.state.accumulatedSeconds) / Double(total), 1.0) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(.white.opacity(0.15)) + Capsule() + .fill(progressColor(progress)) + .frame(width: geo.size.width * progress) + } + } + .frame(height: 4) + } + } + .padding(16) + .activityBackgroundTint(.black.opacity(0.7)) + } + + @ViewBuilder + private var timerText: some View { + if context.state.isTimerRunning, let startDate = context.state.timerStartDate { + let syntheticStart = startDate.addingTimeInterval(-Double(context.state.accumulatedSeconds)) + Text(syntheticStart, style: .timer) + } else { + Text(formatTime(context.state.accumulatedSeconds)) + } + } + + @ViewBuilder + private func remainingLabel(total: Int) -> some View { + if context.state.isTimerRunning, let startDate = context.state.timerStartDate { + let endDate = startDate.addingTimeInterval(Double(total - context.state.accumulatedSeconds)) + HStack(spacing: 2) { + Text("remaining") + Text(endDate, style: .timer) + } + } else { + let remaining = max(0, total - context.state.accumulatedSeconds) + Text("-\(formatTime(remaining)) remaining") + } + } + + private var timerColor: Color { + guard let total = context.state.totalDuration, total > 0 else { return .white } + let elapsed = context.state.accumulatedSeconds + if elapsed >= total { return .red } + if elapsed >= Int(Double(total) * 0.9) { return .orange } + return .white + } + + private func progressColor(_ progress: Double) -> Color { + if progress >= 1.0 { return .red } + if progress >= 0.9 { return .orange } + if progress >= 0.75 { return .yellow } + return .green + } + + private func formatTime(_ seconds: Int) -> String { + let m = seconds / 60 + let s = seconds % 60 + return String(format: "%02d:%02d", m, s) + } +} diff --git a/MacApp/AppCoordinator.swift b/MacApp/AppCoordinator.swift index 9163a51..bf93c3e 100644 --- a/MacApp/AppCoordinator.swift +++ b/MacApp/AppCoordinator.swift @@ -17,6 +17,7 @@ final class AppCoordinator: ObservableObject { let permissionService: PermissionService let keystrokeService: KeystrokeService let connectionManager: MacConnectionManager + let updateChecker = UpdateChecker() // MARK: - View Models @@ -25,7 +26,8 @@ final class AppCoordinator: ObservableObject { connectionManager: connectionManager, preferences: preferences, permissionService: permissionService, - keystrokeService: keystrokeService + keystrokeService: keystrokeService, + updateChecker: updateChecker ) }() @@ -65,6 +67,8 @@ final class AppCoordinator: ObservableObject { // Check permission status permissionService.checkPermissionStatus() + updateChecker.checkIfNeeded() + if !preferences.hasCompletedOnboarding { // First launch - show welcome window showWelcome = true diff --git a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png index 833ba5f..632eb72 100644 Binary files a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png and b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-128.png b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-128.png index ba35ac9..1fdb22c 100644 Binary files a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-128.png and b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-128.png differ diff --git a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-16.png b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-16.png index 1ee1cfe..978cbcc 100644 Binary files a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-16.png and b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-16.png differ diff --git a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-256.png b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-256.png index 4c8fa1e..3e16832 100644 Binary files a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-256.png and b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-256.png differ diff --git a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-32.png b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-32.png index 69c0841..2867620 100644 Binary files a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-32.png and b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-32.png differ diff --git a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-512.png b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-512.png index 1acd28c..c16946f 100644 Binary files a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-512.png and b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-512.png differ diff --git a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-64.png b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-64.png index 6033d1c..c096c8f 100644 Binary files a/MacApp/Assets.xcassets/AppIcon.appiconset/icon-64.png and b/MacApp/Assets.xcassets/AppIcon.appiconset/icon-64.png differ diff --git a/MacApp/Info.plist b/MacApp/Info.plist index 098422a..efb8b96 100644 --- a/MacApp/Info.plist +++ b/MacApp/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2 + 1.8 CFBundleVersion 1 LSUIElement diff --git a/MacApp/PresentationRemoteMacApp.swift b/MacApp/PresentationRemoteMacApp.swift index de8a8f3..dbee395 100644 --- a/MacApp/PresentationRemoteMacApp.swift +++ b/MacApp/PresentationRemoteMacApp.swift @@ -133,6 +133,10 @@ struct SettingsView: View { .padding(.vertical, 8) } + Section("General") { + Toggle("Launch at Login", isOn: $preferences.launchAtLogin) + } + Section("Permissions") { HStack { Text("Accessibility Permission") diff --git a/MacApp/Services/PreferencesManager.swift b/MacApp/Services/PreferencesManager.swift index 65aae41..be63fe2 100644 --- a/MacApp/Services/PreferencesManager.swift +++ b/MacApp/Services/PreferencesManager.swift @@ -1,4 +1,5 @@ import Foundation +import ServiceManagement /// Manages app preferences and first-launch state using UserDefaults /// Provides a centralized, testable interface for all app settings @@ -24,6 +25,20 @@ final class PreferencesManager: ObservableObject { } } + @Published var launchAtLogin: Bool { + didSet { + do { + if launchAtLogin { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + launchAtLogin = oldValue + } + } + } + // MARK: - Private Properties private let defaults: UserDefaults @@ -34,6 +49,7 @@ final class PreferencesManager: ObservableObject { // Load persisted values self.hasCompletedOnboarding = defaults.bool(forKey: Keys.hasCompletedOnboarding) self.debugMenuEnabled = defaults.bool(forKey: Keys.debugMenuEnabled) + self.launchAtLogin = SMAppService.mainApp.status == .enabled } // MARK: - Public Methods diff --git a/MacApp/Services/UpdateChecker.swift b/MacApp/Services/UpdateChecker.swift new file mode 100644 index 0000000..c2addab --- /dev/null +++ b/MacApp/Services/UpdateChecker.swift @@ -0,0 +1,46 @@ +import Foundation + +final class UpdateChecker: ObservableObject { + @Published var availableUpdate: String? + + private let currentVersion: String + private let lastCheckKey = "lastUpdateCheckDate" + private let checkInterval: TimeInterval = 86400 // 24 hours + + init() { + self.currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + } + + func checkIfNeeded() { + if let lastCheck = UserDefaults.standard.object(forKey: lastCheckKey) as? Date, + Date().timeIntervalSince(lastCheck) < checkInterval { + return + } + + Task { + await check() + } + } + + private func check() async { + guard let url = URL(string: "https://api.github.com/repos/douinc/clicker/releases/latest") else { return } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let tagName = json["tag_name"] as? String else { return } + + let latestVersion = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName + + UserDefaults.standard.set(Date(), forKey: lastCheckKey) + + if latestVersion.compare(currentVersion, options: .numeric) == .orderedDescending { + await MainActor.run { + self.availableUpdate = latestVersion + } + } + } catch { + print("Update check failed: \(error.localizedDescription)") + } + } +} diff --git a/MacApp/ViewModels/MenuBarViewModel.swift b/MacApp/ViewModels/MenuBarViewModel.swift index 1a5cefc..e652c22 100644 --- a/MacApp/ViewModels/MenuBarViewModel.swift +++ b/MacApp/ViewModels/MenuBarViewModel.swift @@ -14,6 +14,7 @@ final class MenuBarViewModel: ObservableObject { @Published private(set) var isConnected: Bool = false @Published private(set) var isListening: Bool = false @Published private(set) var hasAccessibilityPermission: Bool = false + @Published private(set) var availableUpdate: String? // MARK: - Services @@ -21,6 +22,7 @@ final class MenuBarViewModel: ObservableObject { let preferences: PreferencesManager let permissionService: PermissionService let keystrokeService: KeystrokeService + let updateChecker: UpdateChecker // MARK: - Private Properties @@ -32,12 +34,14 @@ final class MenuBarViewModel: ObservableObject { connectionManager: MacConnectionManager, preferences: PreferencesManager = .shared, permissionService: PermissionService = .shared, - keystrokeService: KeystrokeService = .shared + keystrokeService: KeystrokeService = .shared, + updateChecker: UpdateChecker ) { self.connectionManager = connectionManager self.preferences = preferences self.permissionService = permissionService self.keystrokeService = keystrokeService + self.updateChecker = updateChecker setupBindings() setupCommandHandler() @@ -82,6 +86,11 @@ final class MenuBarViewModel: ObservableObject { permissionService.$hasAccessibilityPermission .receive(on: DispatchQueue.main) .assign(to: &$hasAccessibilityPermission) + + // Available update + updateChecker.$availableUpdate + .receive(on: DispatchQueue.main) + .assign(to: &$availableUpdate) } private func setupCommandHandler() { diff --git a/MacApp/Views/MenuBarView.swift b/MacApp/Views/MenuBarView.swift index 7616f58..ab0ad30 100644 --- a/MacApp/Views/MenuBarView.swift +++ b/MacApp/Views/MenuBarView.swift @@ -50,6 +50,15 @@ struct MenuBarView: View { Divider() + // Update available + if let update = viewModel.availableUpdate { + Button("Update Available (v\(update))") { + NSWorkspace.shared.open(URL(string: "https://github.com/douinc/clicker/releases/latest")!) + } + + Divider() + } + // Debug menu (conditional) if preferences.debugMenuEnabled { Menu("Debug") { @@ -110,12 +119,6 @@ struct MenuBarIcon: View { let isConnected: Bool var body: some View { - if let nsImage = NSImage(named: "MenuBarIcon") { - Image(nsImage: nsImage) - .renderingMode(.template) - } else { - // Fallback to SF Symbol - Image(systemName: isConnected ? "cursorarrow.click.2" : "cursorarrow.click") - } + Image(systemName: isConnected ? "cursorarrow.click.2" : "cursorarrow.click") } } diff --git a/README.md b/README.md index c889711..9ee74a0 100644 --- a/README.md +++ b/README.md @@ -133,15 +133,17 @@ Download **ClickerRemote** from the [App Store](https://apps.apple.com/us/app/cl | Feature | Description | |---------|-------------| | **Large Touch Targets** | Easy-to-hit buttons designed for stage use | -| **Apple Watch** | Control slides from your wrist with tap buttons or hands-free wrist gestures | -| **Wrist Gestures** | Flick forward for next slide, flick back for previous — with haptic confirmation | +| **Apple Watch** | Control slides from your wrist with tap buttons, Digital Crown, or double-tap gesture | +| **Digital Crown** | Rotate the crown to navigate slides — clockwise for next, counterclockwise for previous | +| **Live Activities** | See your timer on the Lock Screen and Dynamic Island without opening the app | | **Presentation Timer** | Track time with haptic alerts at custom intervals | | **Always-On Display** | Screen stays on while presenting — no connection drops | | **Dark Mode** | Stage-friendly liquid glass aesthetic | | **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 | -| **Stays Active** | Workout session keeps Watch app visible during presentations | +| **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/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/WatchApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png index 0425845..a4fcd34 100644 Binary files a/WatchApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png and b/WatchApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/WatchApp/ClickerWatch.entitlements b/WatchApp/ClickerWatch.entitlements index e10f430..0c67376 100644 --- a/WatchApp/ClickerWatch.entitlements +++ b/WatchApp/ClickerWatch.entitlements @@ -1,8 +1,5 @@ - - com.apple.developer.healthkit - - + diff --git a/WatchApp/ClickerWatchApp.swift b/WatchApp/ClickerWatchApp.swift index 4fd8a36..1addaf0 100644 --- a/WatchApp/ClickerWatchApp.swift +++ b/WatchApp/ClickerWatchApp.swift @@ -1,12 +1,9 @@ import SwiftUI -import HealthKit @main struct ClickerWatchApp: App { @StateObject private var connectionManager = WatchConnectionManager() @StateObject private var sessionManager = ExtendedSessionManager() - @StateObject private var gestureManager = GestureManager() - @StateObject private var workoutManager = WorkoutManager() @Environment(\.scenePhase) var scenePhase var body: some Scene { @@ -14,40 +11,19 @@ struct ClickerWatchApp: App { ContentView() .environmentObject(connectionManager) .environmentObject(sessionManager) - .environmentObject(gestureManager) - .environmentObject(workoutManager) .onAppear { - wireGestureCallbacks() - workoutManager.start() + sessionManager.start() } .onChange(of: scenePhase) { _, phase in switch phase { case .active: sessionManager.start() - workoutManager.start() - if gestureManager.autoToggleWithWrist { - gestureManager.start() - } - case .inactive: - if gestureManager.autoToggleWithWrist { - gestureManager.stop() - } - case .background: - // User explicitly navigated away — stop workout to save battery - workoutManager.stop() + case .inactive, .background: + break @unknown default: break } } } } - - 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 547a548..aeedce6 100644 --- a/WatchApp/ContentView.swift +++ b/WatchApp/ContentView.swift @@ -3,12 +3,14 @@ import WatchKit struct ContentView: View { @EnvironmentObject var connectionManager: WatchConnectionManager - @EnvironmentObject var gestureManager: GestureManager @Environment(\.isLuminanceReduced) var isLuminanceReduced @State private var timerStartDate: Date? @State private var accumulatedTime: TimeInterval = 0 @State private var timerRunning = false @State private var showSettings = false + @State private var crownOffset: Double = 0 + @State private var lastCrownDetent: Int = 0 + @AppStorage("invertCrown") private var invertCrown = false private func elapsedTime(at date: Date) -> TimeInterval { if timerRunning, let start = timerStartDate { @@ -27,78 +29,43 @@ struct ContentView: View { var body: some View { TimelineView(.periodic(from: .now, by: 1.0)) { context in GeometryReader { geometry in - VStack(spacing: 6) { - // Previous slide button + VStack(spacing: 2) { Button(action: { - WKInterfaceDevice.current().play(.directionDown) - connectionManager.previousSlide() + WKInterfaceDevice.current().play(.directionUp) + connectionManager.nextSlide() }) { - 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) + 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()) - // 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) + 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) - .modifier(PrimaryGestureShortcut()) Spacer() // Timer — tap to start/stop, long press to reset VStack(spacing: 2) { Text(formattedTime(at: context.date)) - .font(.system(size: 22, weight: .medium, design: .monospaced)) + .font(.system(size: 20, weight: .medium, design: .monospaced)) .foregroundColor(timerRunning ? .green : .white) HStack(spacing: 4) { @@ -127,7 +94,7 @@ struct ContentView: View { Image(systemName: "gearshape.fill") .font(.system(size: 14, weight: .medium)) .foregroundColor(.secondary) - .frame(width: 30, height: 30) + .frame(width: 36, height: 36) .background(Color.white.opacity(0.08)) .clipShape(Circle()) } @@ -136,45 +103,33 @@ struct ContentView: View { .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) } } + .focusable() + .digitalCrownRotation( + $crownOffset, + from: -10000.0, + through: 10000.0, + sensitivity: .medium, + isContinuous: true + ) + .onChange(of: crownOffset) { _, newValue in + let detent = Int(newValue.rounded()) + guard detent != lastCrownDetent else { return } + let isForward = invertCrown ? (detent < lastCrownDetent) : (detent > lastCrownDetent) + WKInterfaceDevice.current().play(.click) + if isForward { + connectionManager.nextSlide() + } else { + connectionManager.previousSlide() + } + lastCrownDetent = detent + } } // MARK: - Timer @@ -216,5 +171,4 @@ private struct PrimaryGestureShortcut: ViewModifier { #Preview { ContentView() .environmentObject(WatchConnectionManager()) - .environmentObject(GestureManager()) } diff --git a/WatchApp/GestureManager.swift b/WatchApp/GestureManager.swift deleted file mode 100644 index 4af5cc0..0000000 --- a/WatchApp/GestureManager.swift +++ /dev/null @@ -1,205 +0,0 @@ -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 0d94e63..6a27c0e 100644 --- a/WatchApp/Info.plist +++ b/WatchApp/Info.plist @@ -15,15 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.6 + 1.0 CFBundleVersion 1 - 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. - 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 765cc75..81f6953 100644 --- a/WatchApp/SettingsView.swift +++ b/WatchApp/SettingsView.swift @@ -1,47 +1,32 @@ import SwiftUI struct SettingsView: View { - @EnvironmentObject var gestureManager: GestureManager + @AppStorage("invertCrown") private var invertCrown = false var body: some View { List { Section { - Toggle(isOn: $gestureManager.gestureLockEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text("Gesture Lock") - .font(.system(size: 15)) - } + Toggle(isOn: $invertCrown) { + Label("Invert Crown", systemImage: "digitalcrown.horizontal.arrow.counterclockwise") + .font(.system(size: 15)) } } footer: { - Text("After a gesture, lock out further gestures for 3 seconds to prevent accidental triggers") + Text(invertCrown + ? "Crown clockwise = previous slide, counterclockwise = next slide." + : "Crown clockwise = next slide, counterclockwise = previous slide.") .font(.system(size: 11)) .foregroundColor(.secondary) } Section { - Toggle(isOn: $gestureManager.isInverted) { - VStack(alignment: .leading, spacing: 2) { - Text("Invert Gestures") - .font(.system(size: 15)) - } + HStack { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text("Clicker Remote") + .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) { - VStack(alignment: .leading, spacing: 2) { - Text("Auto-toggle with Wrist") - .font(.system(size: 15)) - } - } - } footer: { - Text("Gestures enable on wrist raise and disable on wrist lower") + Text("Use the large button, Digital Crown, or double-tap gesture (watchOS 11+) to advance slides.") .font(.system(size: 11)) .foregroundColor(.secondary) } @@ -53,6 +38,5 @@ struct SettingsView: View { #Preview { NavigationStack { SettingsView() - .environmentObject(GestureManager()) } } diff --git a/WatchApp/WorkoutManager.swift b/WatchApp/WorkoutManager.swift deleted file mode 100644 index 286eaa3..0000000 --- a/WatchApp/WorkoutManager.swift +++ /dev/null @@ -1,61 +0,0 @@ -import HealthKit - -/// Manages an HKWorkoutSession to keep the app frontmost during presentations. -/// Without an active workout session, watchOS dismisses apps on wrist-down. -class WorkoutManager: NSObject, ObservableObject, HKWorkoutSessionDelegate { - private let healthStore = HKHealthStore() - private var session: HKWorkoutSession? - @Published var isActive = false - - func start() { - guard HKHealthStore.isHealthDataAvailable() else { return } - guard session == nil else { return } - - let typesToShare: Set = [HKObjectType.workoutType()] - healthStore.requestAuthorization(toShare: typesToShare, read: nil) { [weak self] success, _ in - guard success else { return } - DispatchQueue.main.async { - self?.startSession() - } - } - } - - private func startSession() { - guard session == nil else { return } - - let config = HKWorkoutConfiguration() - config.activityType = .other - config.locationType = .indoor - - do { - session = try HKWorkoutSession(healthStore: healthStore, configuration: config) - session?.delegate = self - session?.startActivity(with: Date()) - isActive = true - } catch { - print("⌚ Failed to start workout session: \(error)") - } - } - - func stop() { - session?.end() - session = nil - isActive = false - } - - // MARK: - HKWorkoutSessionDelegate - - func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) { - DispatchQueue.main.async { - self.isActive = toState == .running - } - } - - func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { - print("⌚ Workout session error: \(error)") - DispatchQueue.main.async { - self.isActive = false - self.session = nil - } - } -} diff --git a/iPhoneApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/iPhoneApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png index a28024f..a4fcd34 100644 Binary files a/iPhoneApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png and b/iPhoneApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/iPhoneApp/Info.plist b/iPhoneApp/Info.plist index 5d6c711..d7ac169 100644 --- a/iPhoneApp/Info.plist +++ b/iPhoneApp/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.6 + 1.8 CFBundleVersion 1 NSBonjourServices @@ -25,6 +25,8 @@ NSLocalNetworkUsageDescription Clicker needs local network access to find and connect to your Mac. + NSSupportsLiveActivities + UILaunchScreen UISupportedInterfaceOrientations diff --git a/iPhoneApp/LiveActivityManager.swift b/iPhoneApp/LiveActivityManager.swift new file mode 100644 index 0000000..e00edbb --- /dev/null +++ b/iPhoneApp/LiveActivityManager.swift @@ -0,0 +1,84 @@ +import ActivityKit +import Foundation + +class LiveActivityManager { + static let shared = LiveActivityManager() + + private var activity: Activity? + + 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
  • @@ -222,9 +223,9 @@

    ClickerRemoteReceiver (Mac)

    ClickerWatch (Apple Watch)

    • Installed automatically with iOS app
    • -
    • Gesture-based gestures with inversion option
    • -
    • Auto-toggle tied to wrist raise/lower
    • -
    • Workout session keeps app alive
    • +
    • Digital Crown rotation for slide navigation
    • +
    • Double-tap gesture for hands-free next slide
    • +
    • Extended runtime session keeps app alive
    • Relays commands through iPhone instantly
    @@ -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'}}%%