Skip to content
Merged

V1.8 #15

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
18 changes: 8 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
42 changes: 23 additions & 19 deletions Casks/clicker-remote-receiver.rb
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions LiveActivityWidget/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ClickerLiveActivity</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.8</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
9 changes: 9 additions & 0 deletions LiveActivityWidget/LiveActivityBundle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import SwiftUI
import WidgetKit

@main
struct LiveActivityBundle: WidgetBundle {
var body: some Widget {
PresentationLiveActivity()
}
}
208 changes: 208 additions & 0 deletions LiveActivityWidget/PresentationLiveActivity.swift
Original file line number Diff line number Diff line change
@@ -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<PresentationAttributes>

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)
}
}
6 changes: 5 additions & 1 deletion MacApp/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class AppCoordinator: ObservableObject {
let permissionService: PermissionService
let keystrokeService: KeystrokeService
let connectionManager: MacConnectionManager
let updateChecker = UpdateChecker()

// MARK: - View Models

Expand All @@ -25,7 +26,8 @@ final class AppCoordinator: ObservableObject {
connectionManager: connectionManager,
preferences: preferences,
permissionService: permissionService,
keystrokeService: keystrokeService
keystrokeService: keystrokeService,
updateChecker: updateChecker
)
}()

Expand Down Expand Up @@ -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
Expand Down
Binary file modified MacApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified MacApp/Assets.xcassets/AppIcon.appiconset/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified MacApp/Assets.xcassets/AppIcon.appiconset/icon-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified MacApp/Assets.xcassets/AppIcon.appiconset/icon-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified MacApp/Assets.xcassets/AppIcon.appiconset/icon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified MacApp/Assets.xcassets/AppIcon.appiconset/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified MacApp/Assets.xcassets/AppIcon.appiconset/icon-64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion MacApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.2</string>
<string>1.8</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSUIElement</key>
Expand Down
Loading
Loading