react-native-alarms 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,11 +5,34 @@ import SwiftUI
5
5
  #if canImport(AlarmKit)
6
6
  import ActivityKit
7
7
  import AlarmKit
8
+ import AppIntents
8
9
 
9
10
  /// Metadata type required by `AlarmAttributes`; extend it if you add a Live Activity widget.
10
11
  @available(iOS 26.0, *)
11
12
  struct RNAlarmMetadata: AlarmMetadata {}
12
13
 
14
+ /// Backs the "Open" button on the AlarmKit alert. An AlarmKit alert ignores taps on
15
+ /// its body, so this intent (run in the app process, `openAppWhenRun`) is the only
16
+ /// way the alert can open the app and show the React ring screen.
17
+ @available(iOS 26.0, *)
18
+ struct OpenAlarmAppIntent: LiveActivityIntent {
19
+ static let title: LocalizedStringResource = "Open Alarm"
20
+ static let description = IntentDescription("Opens the ringing alarm's screen")
21
+ static let openAppWhenRun: Bool = true
22
+ static let isDiscoverable: Bool = false
23
+
24
+ @Parameter(title: "alarmID")
25
+ var alarmID: String
26
+
27
+ init() {}
28
+ init(alarmID: String) { self.alarmID = alarmID }
29
+
30
+ func perform() async throws -> some IntentResult {
31
+ AlarmKitBackend.shared.openFromAlert(alarmID)
32
+ return .result()
33
+ }
34
+ }
35
+
13
36
  /// Real alarms on iOS 26+: ring through silent mode and Focus with the system alarm UI.
14
37
  /// Only compiled with an SDK that ships AlarmKit (Xcode 26+); older toolchains build
15
38
  /// the app with the notification fallback only.
@@ -26,6 +49,10 @@ final class AlarmKitBackend {
26
49
  /// Alarms we are stopping as part of a programmatic snooze (so the observer
27
50
  /// reports "snoozed" instead of "dismissed").
28
51
  private var snoozing: Set<UUID> = []
52
+ /// Alarms stopped only to silence the system sound while the in-app ring screen
53
+ /// (with its own TTS) is showing. The ring session stays alive until JS calls
54
+ /// stop/snooze, so the observer must not report these as dismissed.
55
+ private var silenced: [UUID: AlarmData] = [:]
29
56
 
30
57
  private static let allWeekdays: [Locale.Weekday] = [
31
58
  .sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
@@ -98,23 +125,16 @@ final class AlarmKitBackend {
98
125
  {
99
126
  let title = LocalizedStringResource(stringLiteral: a.title.isEmpty ? "Alarm" : a.title)
100
127
  let stop = AlarmButton(text: "Stop", textColor: .white, systemImageName: "stop.fill")
101
-
102
- let alert: AlarmPresentation.Alert
103
- var countdown: AlarmPresentation.Countdown?
104
- var duration: Alarm.CountdownDuration?
105
- if a.snoozeEnabled {
106
- let snooze = AlarmButton(text: "Snooze", textColor: .white, systemImageName: "zzz")
107
- alert = AlarmPresentation.Alert(
108
- title: title, stopButton: stop, secondaryButton: snooze, secondaryButtonBehavior: .countdown
109
- )
110
- countdown = AlarmPresentation.Countdown(title: "Snoozed", pauseButton: nil)
111
- duration = Alarm.CountdownDuration(preAlert: nil, postAlert: Double(max(1, a.snoozeMinutes)) * 60)
112
- } else {
113
- alert = AlarmPresentation.Alert(title: title, stopButton: stop)
114
- }
128
+ // The alert's body ignores taps and it allows a single secondary button, so it
129
+ // must be "Open" (via OpenAlarmAppIntent): the React ring screen it opens has
130
+ // its own Snooze/Finish. The system countdown-snooze button is dropped for it.
131
+ let open = AlarmButton(text: "Open", textColor: .white, systemImageName: "arrow.up.forward.app")
132
+ let alert = AlarmPresentation.Alert(
133
+ title: title, stopButton: stop, secondaryButton: open, secondaryButtonBehavior: .custom
134
+ )
115
135
 
116
136
  let attributes = AlarmAttributes<RNAlarmMetadata>(
117
- presentation: AlarmPresentation(alert: alert, countdown: countdown),
137
+ presentation: AlarmPresentation(alert: alert),
118
138
  metadata: RNAlarmMetadata(),
119
139
  tintColor: .blue
120
140
  )
@@ -122,16 +142,18 @@ final class AlarmKitBackend {
122
142
  (a.sound.isEmpty || a.sound == "default") ? .default : .named(a.sound)
123
143
 
124
144
  return AlarmManager.AlarmConfiguration(
125
- countdownDuration: duration,
126
145
  schedule: schedule,
127
146
  attributes: attributes,
147
+ secondaryIntent: OpenAlarmAppIntent(alarmID: a.id),
128
148
  sound: sound
129
149
  )
130
150
  }
131
151
 
132
152
  func cancel(_ id: String) {
133
- try? manager.cancel(id: Self.uuid(for: id))
134
- try? manager.cancel(id: Self.uuid(for: id, snooze: true))
153
+ for uuid in [Self.uuid(for: id), Self.uuid(for: id, snooze: true)] {
154
+ try? manager.cancel(id: uuid)
155
+ silenced.removeValue(forKey: uuid)
156
+ }
135
157
  }
136
158
 
137
159
  func cancelAll() {
@@ -139,19 +161,60 @@ final class AlarmKitBackend {
139
161
  }
140
162
 
141
163
  func stop(_ id: String) {
142
- try? manager.stop(id: Self.uuid(for: id))
143
- try? manager.stop(id: Self.uuid(for: id, snooze: true))
164
+ for uuid in [Self.uuid(for: id), Self.uuid(for: id, snooze: true)] {
165
+ try? manager.stop(id: uuid)
166
+ // Ended a session we had silenced earlier: the observer saw nothing change,
167
+ // so report the dismissal (and re-arm intervals) here instead.
168
+ if let a = silenced.removeValue(forKey: uuid) {
169
+ onEvent?(AlarmModule.eventDismissed, a)
170
+ if a.repeatType == "interval" { Task { try? await self.schedule(a) } }
171
+ }
172
+ }
144
173
  }
145
174
 
146
- /// Silences the ringing alarm (if any) and re-rings once in `minutes`,
147
- /// equivalent to Android's `AlarmScheduler.scheduleSnooze`.
148
- func snooze(_ a: AlarmData, minutes: Int) async throws {
175
+ /// "Open" tapped on the system alert (OpenAlarmAppIntent). The tap stops the
176
+ /// AlarmKit alarm, so adopt the session as `silenced` — still ringing as far as
177
+ /// JS is concerned — and show the React ring screen once the app is frontmost.
178
+ func openFromAlert(_ id: String) {
179
+ guard let a = AlarmStore.get(id) else { return }
149
180
  let current = (try? manager.alarms) ?? []
150
181
  for uuid in [Self.uuid(for: a.id), Self.uuid(for: a.id, snooze: true)]
151
182
  where current.contains(where: { $0.id == uuid && $0.state == .alerting }) {
152
- snoozing.insert(uuid)
183
+ silenced[uuid] = a
184
+ try? manager.stop(id: uuid)
185
+ }
186
+ // The system may have stopped it before we ran; the session is still ours.
187
+ if !silenced.values.contains(where: { $0.id == a.id }) {
188
+ silenced[Self.uuid(for: a.id)] = a
189
+ }
190
+ DispatchQueue.main.async { AlarmRingPresenter.present(a, bridge: nil) }
191
+ }
192
+
193
+ /// Stops the system alert's sound/UI while the in-app ring screen takes over.
194
+ /// The session stays "ringing" (see `silenced`) until JS stops or snoozes it.
195
+ func silence(_ id: String) {
196
+ let current = (try? manager.alarms) ?? []
197
+ for uuid in [Self.uuid(for: id), Self.uuid(for: id, snooze: true)]
198
+ where current.contains(where: { $0.id == uuid && $0.state == .alerting }) {
199
+ silenced[uuid] = stored(for: uuid)
153
200
  try? manager.stop(id: uuid)
154
201
  }
202
+ }
203
+
204
+ /// Silences the ringing alarm (if any) and re-rings once in `minutes`,
205
+ /// equivalent to Android's `AlarmScheduler.scheduleSnooze`.
206
+ func snooze(_ a: AlarmData, minutes: Int) async throws {
207
+ let current = (try? manager.alarms) ?? []
208
+ var wasSilenced = false
209
+ for uuid in [Self.uuid(for: a.id), Self.uuid(for: a.id, snooze: true)] {
210
+ if silenced.removeValue(forKey: uuid) != nil { wasSilenced = true }
211
+ if current.contains(where: { $0.id == uuid && $0.state == .alerting }) {
212
+ snoozing.insert(uuid)
213
+ try? manager.stop(id: uuid)
214
+ }
215
+ }
216
+ // Snoozed a session we had silenced: the observer saw nothing change, report it here.
217
+ if wasSilenced { onEvent?(AlarmModule.eventSnoozed, a) }
155
218
  let date = Date().addingTimeInterval(Double(max(1, minutes)) * 60)
156
219
  _ = try await manager.schedule(
157
220
  id: Self.uuid(for: a.id, snooze: true),
@@ -161,10 +224,11 @@ final class AlarmKitBackend {
161
224
  }
162
225
 
163
226
  func ringingAlarm() -> AlarmData? {
164
- guard let alerting = ((try? manager.alarms) ?? []).first(where: { $0.state == .alerting }) else {
165
- return nil
227
+ if let alerting = ((try? manager.alarms) ?? []).first(where: { $0.state == .alerting }) {
228
+ return stored(for: alerting.id)
166
229
  }
167
- return stored(for: alerting.id)
230
+ // Sound silenced for the in-app ring screen, but the session is still live.
231
+ return silenced.values.first
168
232
  }
169
233
 
170
234
  // MARK: - Events
@@ -187,14 +251,17 @@ final class AlarmKitBackend {
187
251
  for (uuid, state) in states where lastStates[uuid] != state {
188
252
  guard let a = stored(for: uuid) else { continue }
189
253
  if state == .alerting {
254
+ silenced.removeValue(forKey: uuid) // ringing again supersedes any old silence
190
255
  onEvent?(AlarmModule.eventFired, a)
191
256
  } else if lastStates[uuid] == .alerting {
257
+ if silenced[uuid] != nil { continue } // silenced for the ring screen, not over yet
192
258
  onEvent?(state == .countdown ? AlarmModule.eventSnoozed : AlarmModule.eventDismissed, a)
193
259
  }
194
260
  }
195
261
 
196
262
  // One-off (`.fixed`) alarms disappear once stopped.
197
263
  for (uuid, previous) in lastStates where states[uuid] == nil && previous == .alerting {
264
+ if silenced[uuid] != nil { continue } // silenced for the ring screen, not over yet
198
265
  guard let a = stored(for: uuid) else { continue }
199
266
  if snoozing.remove(uuid) != nil {
200
267
  onEvent?(AlarmModule.eventSnoozed, a)
@@ -16,6 +16,7 @@ final class AlarmModule: RCTEventEmitter {
16
16
  static let eventSnoozed = "onAlarmSnoozed"
17
17
 
18
18
  private var hasListeners = false
19
+ private var activeObserver: NSObjectProtocol?
19
20
 
20
21
  override init() {
21
22
  super.init()
@@ -27,10 +28,26 @@ final class AlarmModule: RCTEventEmitter {
27
28
  AlarmKitBackend.shared.startObserving()
28
29
  }
29
30
  #endif
30
- // App launched by tapping the alarm notification before this module existed.
31
- if let ringing = NotificationBackend.shared.ringing {
32
- DispatchQueue.main.async { [weak self] in AlarmRingPresenter.present(ringing, bridge: self?.bridge) }
33
- }
31
+ // App launched by tapping the alarm notification/alert before this module existed.
32
+ DispatchQueue.main.async { [weak self] in self?.presentIfRinging() }
33
+ // Tapping an AlarmKit alert (and some notification taps on a warm start) only
34
+ // foregrounds the app without any delegate callback, so also check on activation.
35
+ activeObserver = NotificationCenter.default.addObserver(
36
+ forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
37
+ ) { [weak self] _ in self?.presentIfRinging() }
38
+ }
39
+
40
+ deinit {
41
+ if let activeObserver { NotificationCenter.default.removeObserver(activeObserver) }
42
+ }
43
+
44
+ /// Shows the ring screen when a backend says an alarm is ringing right now.
45
+ private func presentIfRinging() {
46
+ var ringing = NotificationBackend.shared.ringing
47
+ #if canImport(AlarmKit)
48
+ if #available(iOS 26.0, *), ringing == nil { ringing = AlarmKitBackend.shared.ringingAlarm() }
49
+ #endif
50
+ if let ringing { AlarmRingPresenter.present(ringing, bridge: bridge) }
34
51
  }
35
52
 
36
53
  private func handle(_ name: String, _ alarm: AlarmData) {
@@ -15,12 +15,17 @@ final class AlarmRingPresenter {
15
15
 
16
16
  static func present(_ alarm: AlarmData, bridge: RCTBridge?, attempt: Int = 0) {
17
17
  DispatchQueue.main.async {
18
- if controller != nil, alarmId == alarm.id { return }
19
- // Cold start from a notification tap: the root surface or window may not be
20
- // ready yet, so retry briefly.
18
+ if controller != nil, alarmId == alarm.id {
19
+ // Already showing (e.g. presented while backgrounded, now foregrounded):
20
+ // still make sure the native sound yields to the screen's own TTS.
21
+ silenceIfActive(alarm.id)
22
+ return
23
+ }
24
+ // Cold start from a notification tap or the AlarmKit "Open" button: the root
25
+ // surface or window may not be ready yet, so retry for up to ~15 s.
21
26
  guard let surface = makeSurface(bridge, props: alarm.toDictionary()),
22
27
  let top = topViewController() else {
23
- if attempt < 20 {
28
+ if attempt < 60 {
24
29
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
25
30
  present(alarm, bridge: bridge, attempt: attempt + 1)
26
31
  }
@@ -42,6 +47,7 @@ final class AlarmRingPresenter {
42
47
  top.present(vc, animated: true)
43
48
  controller = vc
44
49
  alarmId = alarm.id
50
+ silenceIfActive(alarm.id)
45
51
  }
46
52
  if let existing = controller {
47
53
  existing.dismiss(animated: false, completion: show)
@@ -51,6 +57,18 @@ final class AlarmRingPresenter {
51
57
  }
52
58
  }
53
59
 
60
+ /// The ring screen speaks the task via TTS, and iOS won't play it over the
61
+ /// notification / AlarmKit sound, so stop that sound once the screen is up.
62
+ /// Only while the app is frontmost: in the background the system alert is the
63
+ /// only thing the user can hear, so it must keep ringing.
64
+ private static func silenceIfActive(_ id: String) {
65
+ guard UIApplication.shared.applicationState == .active else { return }
66
+ NotificationBackend.shared.silence(id)
67
+ #if canImport(AlarmKit)
68
+ if #available(iOS 26.0, *) { AlarmKitBackend.shared.silence(id) }
69
+ #endif
70
+ }
71
+
54
72
  /// Called when ringing ends for any reason (stop, snooze, cancel).
55
73
  static func dismiss(_ id: String? = nil) {
56
74
  DispatchQueue.main.async {
@@ -169,6 +169,12 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
169
169
  if ringing?.id == a.id { ringing = nil }
170
170
  }
171
171
 
172
+ /// Stops the notification's sound (by clearing the delivered notification) while the
173
+ /// in-app ring screen takes over; `ringing` stays set so the session is still live.
174
+ func silence(_ id: String) {
175
+ center.removeDeliveredNotifications(withIdentifiers: ids(for: id))
176
+ }
177
+
172
178
  func cancel(_ id: String) {
173
179
  center.removePendingNotificationRequests(withIdentifiers: ids(for: id))
174
180
  center.removeDeliveredNotifications(withIdentifiers: ids(for: id))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-alarms",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "A powerful, cross-platform alarm management library for React Native",
5
5
  "private": false,
6
6
  "main": "index.ts",