From cd9a8afe0b06a402f64cd4e183d41be22d4e79c8 Mon Sep 17 00:00:00 2001 From: Octavio Froid Date: Tue, 3 Feb 2026 12:28:00 +0100 Subject: [PATCH] feat: add location-based reminder triggers Add --location, --leaving, and --radius flags to the add command: - --location: Address string (will be geocoded) - --leaving: Trigger when leaving (default: arriving) - --radius: Geofence radius in meters (default: 100) Examples: remindctl add "Check mailbox" --location "50 West St, New York, NY" remindctl add "Lock up" --location "Home" --leaving --- Sources/RemindCore/EventKitStore.swift | 35 +++++++++++++++++++ Sources/RemindCore/Models.swift | 37 ++++++++++++++++++++- Sources/remindctl/Commands/AddCommand.swift | 32 +++++++++++++++++- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index e44dc89..abbf939 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -1,3 +1,4 @@ +import CoreLocation import EventKit import Foundation @@ -103,6 +104,13 @@ public actor RemindersStore { if let dueDate = draft.dueDate { reminder.dueDateComponents = calendarComponents(from: dueDate) } + + // Handle location-based alarm + if let locationTrigger = draft.location { + let alarm = try await createLocationAlarm(from: locationTrigger) + reminder.addAlarm(alarm) + } + try eventStore.save(reminder, commit: true) return ReminderItem( id: reminder.calendarItemIdentifier, @@ -117,6 +125,33 @@ public actor RemindersStore { ) } + private func createLocationAlarm(from trigger: LocationTrigger) async throws -> EKAlarm { + let structuredLocation: EKStructuredLocation + + if let lat = trigger.latitude, let lon = trigger.longitude { + // Use provided coordinates + structuredLocation = EKStructuredLocation(title: trigger.address) + structuredLocation.geoLocation = CLLocation(latitude: lat, longitude: lon) + } else { + // Geocode the address + let geocoder = CLGeocoder() + let placemarks = try await geocoder.geocodeAddressString(trigger.address) + guard let placemark = placemarks.first, let location = placemark.location else { + throw RemindCoreError.operationFailed("Could not geocode address: \(trigger.address)") + } + structuredLocation = EKStructuredLocation(title: trigger.address) + structuredLocation.geoLocation = location + } + + structuredLocation.radius = trigger.radius + + let alarm = EKAlarm() + alarm.structuredLocation = structuredLocation + alarm.proximity = trigger.proximity == .arriving ? .enter : .leave + + return alarm + } + public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem { let reminder = try reminder(withID: id) diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 5f4fe90..92082c7 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -77,17 +77,52 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { } } +public enum LocationProximity: String, Codable, CaseIterable, Sendable { + case arriving = "arriving" + case leaving = "leaving" +} + +public struct LocationTrigger: Sendable { + public let address: String + public let latitude: Double? + public let longitude: Double? + public let radius: Double // in meters + public let proximity: LocationProximity + + public init( + address: String, + latitude: Double? = nil, + longitude: Double? = nil, + radius: Double = 100, + proximity: LocationProximity = .arriving + ) { + self.address = address + self.latitude = latitude + self.longitude = longitude + self.radius = radius + self.proximity = proximity + } +} + public struct ReminderDraft: Sendable { public let title: String public let notes: String? public let dueDate: Date? public let priority: ReminderPriority + public let location: LocationTrigger? - public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) { + public init( + title: String, + notes: String?, + dueDate: Date?, + priority: ReminderPriority, + location: LocationTrigger? = nil + ) { self.title = title self.notes = notes self.dueDate = dueDate self.priority = priority + self.location = location } } diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index 571bc25..f440eb8 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -24,6 +24,21 @@ enum AddCommand { help: "none|low|medium|high", parsing: .singleValue ), + .make( + label: "location", + names: [.long("location")], + help: "Location address for geofence trigger", + parsing: .singleValue + ), + .make( + label: "radius", + names: [.long("radius")], + help: "Geofence radius in meters (default: 100)", + parsing: .singleValue + ), + ], + flags: [ + .make(label: "leaving", names: [.long("leaving")], help: "Trigger when leaving location (default: arriving)"), ] ) ), @@ -31,6 +46,8 @@ enum AddCommand { "remindctl add \"Buy milk\"", "remindctl add --title \"Call mom\" --list Personal --due tomorrow", "remindctl add \"Review docs\" --priority high", + "remindctl add \"Check mailbox\" --location \"50 West St, New York, NY\"", + "remindctl add \"Lock up\" --location \"Home\" --leaving", ] ) { values, runtime in let titleOption = values.option("title") @@ -56,10 +73,23 @@ enum AddCommand { let notes = values.option("notes") let dueValue = values.option("due") let priorityValue = values.option("priority") + let locationValue = values.option("location") + let isLeaving = values.flag("leaving") + let radiusValue = values.option("radius") let dueDate = try dueValue.map(CommandHelpers.parseDueDate) let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none + // Build location trigger if specified + let locationTrigger: LocationTrigger? + if let address = locationValue { + let radius = radiusValue.flatMap { Double($0) } ?? 100.0 + let proximity: LocationProximity = isLeaving ? .leaving : .arriving + locationTrigger = LocationTrigger(address: address, radius: radius, proximity: proximity) + } else { + locationTrigger = nil + } + let store = RemindersStore() try await store.requestAccess() @@ -73,7 +103,7 @@ enum AddCommand { throw RemindCoreError.operationFailed("No default list found. Specify --list.") } - let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority) + let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority, location: locationTrigger) let reminder = try await store.createReminder(draft, listName: targetList) OutputRenderer.printReminder(reminder, format: runtime.outputFormat) }