feat: add location reminder triggers
Co-authored-by: Octavio Froid <froid@bohm.com>
This commit is contained in:
parent
20e868d615
commit
85a589366e
@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-05-04
|
||||
- Add location-based reminder triggers via `--location`, `--leaving`, and `--radius`
|
||||
- Add simple recurrence support via `--repeat` and `--no-repeat`
|
||||
- Add EventKit alarm support via `--alarm` and `--clear-alarm`
|
||||
- Add reminder `url` to JSON output when EventKit exposes one
|
||||
|
||||
@ -16,6 +16,7 @@ let package = Package(
|
||||
name: "RemindCore",
|
||||
dependencies: [],
|
||||
linkerSettings: [
|
||||
.linkedFramework("CoreLocation"),
|
||||
.linkedFramework("EventKit"),
|
||||
]
|
||||
),
|
||||
|
||||
@ -51,6 +51,7 @@ remindctl list Projects --create
|
||||
remindctl add "Buy milk"
|
||||
remindctl add --title "Call mom" --list Personal --due tomorrow
|
||||
remindctl add "Meeting" --due "2026-01-03 09:00" --alarm "2026-01-03 08:55"
|
||||
remindctl add "Check mailbox" --location "1 Apple Park Way, Cupertino, CA"
|
||||
remindctl add "Take vitamins" --due tomorrow --repeat daily
|
||||
remindctl edit 1 --title "New title" --due 2026-01-04 --clear-alarm
|
||||
remindctl list Work Errands # show reminders from multiple lists
|
||||
@ -64,8 +65,8 @@ remindctl authorize # request permissions
|
||||
- `--json` emits JSON arrays/objects.
|
||||
- `--plain` emits tab-separated lines.
|
||||
- `--quiet` emits counts only.
|
||||
- JSON includes EventKit metadata such as `creationDate`, `lastModifiedDate`, `url`, `alarmDate`, and
|
||||
`recurrenceRule` when available.
|
||||
- JSON includes EventKit metadata such as `creationDate`, `lastModifiedDate`, `url`, `alarmDate`,
|
||||
`locationTrigger`, and `recurrenceRule` when available.
|
||||
File/image attachments are not exposed by EventKit.
|
||||
|
||||
## Date formats
|
||||
@ -80,6 +81,9 @@ Date-only due inputs create all-day reminders; date-time inputs create timed rem
|
||||
Timed due reminders get a notification alarm at the due time unless `--alarm` sets a different alarm time.
|
||||
Use `edit <id> --clear-alarm` to remove an alarm.
|
||||
|
||||
Use `--location <address>` on `add` to create a location trigger. Add `--leaving` to trigger when leaving
|
||||
instead of arriving, and `--radius <meters>` to customize the geofence radius.
|
||||
|
||||
## Repeat
|
||||
Use `--repeat` with `add` or `edit` for simple recurrence:
|
||||
- `daily`, `weekly`, `biweekly`, `monthly`, `yearly`
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import CoreLocation
|
||||
import EventKit
|
||||
import Foundation
|
||||
|
||||
@ -116,6 +117,9 @@ public actor RemindersStore {
|
||||
if let recurrenceRule = draft.recurrenceRule {
|
||||
replaceRecurrence(on: reminder, with: recurrenceRule)
|
||||
}
|
||||
if let locationTrigger = draft.locationTrigger {
|
||||
reminder.addAlarm(try await locationAlarm(from: locationTrigger))
|
||||
}
|
||||
try eventStore.save(reminder, commit: true)
|
||||
return item(from: reminder)
|
||||
}
|
||||
@ -211,6 +215,7 @@ extension RemindersStore {
|
||||
let dueDateIsAllDay: Bool
|
||||
let alarmDate: Date?
|
||||
let recurrenceRule: RecurrenceRule?
|
||||
let locationTrigger: LocationTrigger?
|
||||
let listID: String
|
||||
let listName: String
|
||||
}
|
||||
@ -234,6 +239,7 @@ extension RemindersStore {
|
||||
dueDateIsAllDay: isAllDay(components),
|
||||
alarmDate: Self.alarmDate(from: reminder),
|
||||
recurrenceRule: Self.recurrenceRule(from: reminder),
|
||||
locationTrigger: Self.locationTrigger(from: reminder),
|
||||
listID: reminder.calendar.calendarIdentifier,
|
||||
listName: reminder.calendar.title
|
||||
)
|
||||
@ -257,6 +263,7 @@ extension RemindersStore {
|
||||
dueDateIsAllDay: data.dueDateIsAllDay,
|
||||
alarmDate: data.alarmDate,
|
||||
recurrenceRule: data.recurrenceRule,
|
||||
locationTrigger: data.locationTrigger,
|
||||
listID: data.listID,
|
||||
listName: data.listName
|
||||
)
|
||||
@ -310,6 +317,7 @@ extension RemindersStore {
|
||||
dueDateIsAllDay: isAllDay(components),
|
||||
alarmDate: Self.alarmDate(from: reminder),
|
||||
recurrenceRule: Self.recurrenceRule(from: reminder),
|
||||
locationTrigger: Self.locationTrigger(from: reminder),
|
||||
listID: reminder.calendar.calendarIdentifier,
|
||||
listName: reminder.calendar.title
|
||||
)
|
||||
@ -344,6 +352,44 @@ extension RemindersStore {
|
||||
guard let frequency = RecurrenceFrequency(eventKitFrequency: rule.frequency) else { return nil }
|
||||
return RecurrenceRule(frequency: frequency, interval: rule.interval)
|
||||
}
|
||||
|
||||
private func locationAlarm(from trigger: LocationTrigger) async throws -> EKAlarm {
|
||||
let structuredLocation = EKStructuredLocation(title: trigger.address)
|
||||
let location: CLLocation
|
||||
if let latitude = trigger.latitude, let longitude = trigger.longitude {
|
||||
location = CLLocation(latitude: latitude, longitude: longitude)
|
||||
} else {
|
||||
let placemarks = try await CLGeocoder().geocodeAddressString(trigger.address)
|
||||
guard let geocodedLocation = placemarks.first?.location else {
|
||||
throw RemindCoreError.operationFailed("Could not geocode location: \(trigger.address)")
|
||||
}
|
||||
location = geocodedLocation
|
||||
}
|
||||
|
||||
structuredLocation.geoLocation = location
|
||||
structuredLocation.radius = trigger.radius
|
||||
|
||||
let alarm = EKAlarm()
|
||||
alarm.structuredLocation = structuredLocation
|
||||
alarm.proximity = trigger.proximity == .arriving ? .enter : .leave
|
||||
return alarm
|
||||
}
|
||||
|
||||
private static func locationTrigger(from reminder: EKReminder) -> LocationTrigger? {
|
||||
guard let alarm = reminder.alarms?.first(where: { $0.structuredLocation != nil }),
|
||||
let structuredLocation = alarm.structuredLocation,
|
||||
let proximity = LocationProximity(eventKitProximity: alarm.proximity)
|
||||
else { return nil }
|
||||
|
||||
let coordinate = structuredLocation.geoLocation?.coordinate
|
||||
return LocationTrigger(
|
||||
address: structuredLocation.title ?? "",
|
||||
latitude: coordinate?.latitude,
|
||||
longitude: coordinate?.longitude,
|
||||
radius: structuredLocation.radius,
|
||||
proximity: proximity
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension RecurrenceFrequency {
|
||||
@ -381,3 +427,16 @@ extension RecurrenceRule {
|
||||
frequency.eventKitFrequency
|
||||
}
|
||||
}
|
||||
|
||||
extension LocationProximity {
|
||||
fileprivate init?(eventKitProximity: EKAlarmProximity) {
|
||||
switch eventKitProximity {
|
||||
case .enter:
|
||||
self = .arriving
|
||||
case .leave:
|
||||
self = .leaving
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,6 +74,33 @@ public struct ReminderList: Identifiable, Codable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum LocationProximity: String, Codable, CaseIterable, Sendable {
|
||||
case arriving
|
||||
case leaving
|
||||
}
|
||||
|
||||
public struct LocationTrigger: Codable, Sendable, Equatable {
|
||||
public let address: String
|
||||
public let latitude: Double?
|
||||
public let longitude: Double?
|
||||
public let radius: Double
|
||||
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 ReminderItem: Identifiable, Codable, Sendable, Equatable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
@ -88,6 +115,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
|
||||
public let dueDateIsAllDay: Bool
|
||||
public let alarmDate: Date?
|
||||
public let recurrenceRule: RecurrenceRule?
|
||||
public let locationTrigger: LocationTrigger?
|
||||
public let listID: String
|
||||
public let listName: String
|
||||
|
||||
@ -105,6 +133,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
|
||||
dueDateIsAllDay: Bool = false,
|
||||
alarmDate: Date? = nil,
|
||||
recurrenceRule: RecurrenceRule? = nil,
|
||||
locationTrigger: LocationTrigger? = nil,
|
||||
listID: String,
|
||||
listName: String
|
||||
) {
|
||||
@ -121,6 +150,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
|
||||
self.dueDateIsAllDay = dueDateIsAllDay
|
||||
self.alarmDate = alarmDate
|
||||
self.recurrenceRule = recurrenceRule
|
||||
self.locationTrigger = locationTrigger
|
||||
self.listID = listID
|
||||
self.listName = listName
|
||||
}
|
||||
@ -132,6 +162,7 @@ public struct ReminderDraft: Sendable {
|
||||
public let dueDate: ParsedUserDate?
|
||||
public let alarmDate: ParsedUserDate?
|
||||
public let recurrenceRule: RecurrenceRule?
|
||||
public let locationTrigger: LocationTrigger?
|
||||
public let priority: ReminderPriority
|
||||
|
||||
public init(
|
||||
@ -140,6 +171,7 @@ public struct ReminderDraft: Sendable {
|
||||
dueDate: ParsedUserDate?,
|
||||
alarmDate: ParsedUserDate? = nil,
|
||||
recurrenceRule: RecurrenceRule? = nil,
|
||||
locationTrigger: LocationTrigger? = nil,
|
||||
priority: ReminderPriority
|
||||
) {
|
||||
self.title = title
|
||||
@ -147,6 +179,7 @@ public struct ReminderDraft: Sendable {
|
||||
self.dueDate = dueDate
|
||||
self.alarmDate = alarmDate
|
||||
self.recurrenceRule = recurrenceRule
|
||||
self.locationTrigger = locationTrigger
|
||||
self.priority = priority
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,18 @@ enum AddCommand {
|
||||
.make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue),
|
||||
.make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue),
|
||||
.make(label: "alarm", names: [.short("a"), .long("alarm")], help: "Alarm date", 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
|
||||
),
|
||||
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue),
|
||||
.make(
|
||||
label: "repeat",
|
||||
@ -31,6 +43,9 @@ enum AddCommand {
|
||||
help: "none|low|medium|high",
|
||||
parsing: .singleValue
|
||||
),
|
||||
],
|
||||
flags: [
|
||||
.make(label: "leaving", names: [.long("leaving")], help: "Trigger when leaving location")
|
||||
]
|
||||
)
|
||||
),
|
||||
@ -38,6 +53,7 @@ enum AddCommand {
|
||||
"remindctl add \"Buy milk\"",
|
||||
"remindctl add --title \"Call mom\" --list Personal --due tomorrow",
|
||||
"remindctl add \"Call mom\" --due \"2026-01-03 09:00\" --alarm \"2026-01-03 08:55\"",
|
||||
"remindctl add \"Check mailbox\" --location \"1 Apple Park Way, Cupertino, CA\"",
|
||||
"remindctl add \"Take vitamins\" --due tomorrow --repeat daily",
|
||||
"remindctl add \"Review docs\" --priority high",
|
||||
]
|
||||
@ -65,11 +81,18 @@ enum AddCommand {
|
||||
let notes = values.option("notes")
|
||||
let dueValue = values.option("due")
|
||||
let alarmValue = values.option("alarm")
|
||||
let locationValue = values.option("location")
|
||||
let radiusValue = values.option("radius")
|
||||
let repeatValue = values.option("repeat")
|
||||
let priorityValue = values.option("priority")
|
||||
|
||||
let dueDate = try dueValue.map(CommandHelpers.parseDueDate)
|
||||
let alarmDate = try alarmValue.map(CommandHelpers.parseDueDate)
|
||||
let locationTrigger = try makeLocationTrigger(
|
||||
location: locationValue,
|
||||
radius: radiusValue,
|
||||
leaving: values.flag("leaving")
|
||||
)
|
||||
let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence)
|
||||
let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none
|
||||
|
||||
@ -92,10 +115,38 @@ enum AddCommand {
|
||||
dueDate: dueDate,
|
||||
alarmDate: alarmDate,
|
||||
recurrenceRule: recurrenceRule,
|
||||
locationTrigger: locationTrigger,
|
||||
priority: priority
|
||||
)
|
||||
let reminder = try await store.createReminder(draft, listName: targetList)
|
||||
OutputRenderer.printReminder(reminder, format: runtime.outputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeLocationTrigger(
|
||||
location: String?,
|
||||
radius: String?,
|
||||
leaving: Bool
|
||||
) throws -> LocationTrigger? {
|
||||
if location == nil {
|
||||
if radius != nil || leaving {
|
||||
throw RemindCoreError.operationFailed("Use --location with --radius or --leaving")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
guard let location else { return nil }
|
||||
let radius = try radius.map(parseRadius) ?? 100
|
||||
return LocationTrigger(
|
||||
address: location,
|
||||
radius: radius,
|
||||
proximity: leaving ? .leaving : .arriving
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseRadius(_ value: String) throws -> Double {
|
||||
guard let radius = Double(value), radius > 0 else {
|
||||
throw RemindCoreError.operationFailed("Invalid radius: \"\(value)\"")
|
||||
}
|
||||
return radius
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,13 @@ struct ReminderItemCodingTests {
|
||||
dueDate: nil,
|
||||
alarmDate: Date(timeIntervalSince1970: 1_700_000_300),
|
||||
recurrenceRule: RecurrenceRule(frequency: .weekly, interval: 2),
|
||||
locationTrigger: LocationTrigger(
|
||||
address: "1 Apple Park Way",
|
||||
latitude: 37.3349,
|
||||
longitude: -122.0090,
|
||||
radius: 100,
|
||||
proximity: .arriving
|
||||
),
|
||||
listID: "list",
|
||||
listName: "Inbox"
|
||||
)
|
||||
@ -33,5 +40,6 @@ struct ReminderItemCodingTests {
|
||||
#expect(json.contains(#""url":"https:\/\/example.com""#))
|
||||
#expect(json.contains(#""alarmDate""#))
|
||||
#expect(json.contains(#""recurrenceRule""#))
|
||||
#expect(json.contains(#""locationTrigger""#))
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,12 +22,14 @@ struct HelpPrinterTests {
|
||||
#expect(joined.contains("authorize"))
|
||||
}
|
||||
|
||||
@Test("Add and edit help include alarm and repeat options")
|
||||
func alarmAndRepeatHelp() {
|
||||
@Test("Add and edit help include alarm, location, and repeat options")
|
||||
func alarmLocationAndRepeatHelp() {
|
||||
let addHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: AddCommand.spec).joined(separator: "\n")
|
||||
let editHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: EditCommand.spec).joined(separator: "\n")
|
||||
|
||||
#expect(addHelp.contains("--alarm"))
|
||||
#expect(addHelp.contains("--location"))
|
||||
#expect(addHelp.contains("--leaving"))
|
||||
#expect(addHelp.contains("--repeat"))
|
||||
#expect(editHelp.contains("--alarm"))
|
||||
#expect(editHelp.contains("--clear-alarm"))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user