SnoozerView.swift 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. // LoopFollow
  2. // SnoozerView.swift
  3. import SwiftUI
  4. struct SnoozerView: View {
  5. @StateObject private var vm = SnoozerViewModel()
  6. @ObservedObject var showDisplayName = Storage.shared.showDisplayName
  7. @ObservedObject var minAgoText = Observable.shared.minAgoText
  8. @ObservedObject var bgText = Observable.shared.bgText
  9. @ObservedObject var bgTextColor = Observable.shared.bgTextColor
  10. @ObservedObject var directionText = Observable.shared.directionText
  11. @ObservedObject var deltaText = Observable.shared.deltaText
  12. @ObservedObject var bgStale = Observable.shared.bgStale
  13. @ObservedObject var bg = Observable.shared.bg
  14. @ObservedObject var snoozerEmoji = Storage.shared.snoozerEmoji
  15. @ObservedObject private var cfgStore = Storage.shared.alarmConfiguration
  16. // Snoozer Bar state
  17. @State private var showSnoozerBar: Bool = false
  18. @State private var showDatePickerDate: Bool = false
  19. @State private var showDatePickerTime: Bool = false
  20. @State private var autoHideTask: DispatchWorkItem? = nil
  21. @State private var lastActiveState: Bool = false
  22. private var isGlobalSnoozeActive: Bool {
  23. if let until = cfgStore.value.snoozeUntil { return until > Date() }
  24. return false
  25. }
  26. var body: some View {
  27. GeometryReader { geo in
  28. let isLandscape = geo.size.width > geo.size.height
  29. let barShowing = showSnoozerBar || isGlobalSnoozeActive
  30. let landscapeScale: CGFloat = 0.8
  31. ZStack {
  32. Color.black.ignoresSafeArea()
  33. VStack(spacing: 0) {
  34. if isLandscape {
  35. HStack(spacing: 0) {
  36. leftColumn(isLandscape: true, barShowing: barShowing)
  37. rightColumn(isLandscape: true)
  38. }
  39. } else {
  40. VStack(spacing: 0) {
  41. leftColumn(isLandscape: false, barShowing: barShowing)
  42. rightColumn(isLandscape: false)
  43. }
  44. }
  45. }
  46. .contentShape(Rectangle())
  47. .onTapGesture { presentSnoozerBar() }
  48. .onAppear {
  49. presentSnoozerBar()
  50. lastActiveState = isGlobalSnoozeActive
  51. }
  52. .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
  53. let active = isGlobalSnoozeActive
  54. if lastActiveState != active {
  55. lastActiveState = active
  56. if active {
  57. showSnoozerBar = true
  58. cancelAutoHide()
  59. } else {
  60. scheduleAutoHide()
  61. }
  62. }
  63. }
  64. .onReceive(vm.$activeAlarm) { alarm in
  65. if alarm != nil {
  66. showSnoozerBar = true
  67. cancelAutoHide()
  68. } else if !isGlobalSnoozeActive {
  69. scheduleAutoHide()
  70. }
  71. }
  72. .onChange(of: isGlobalSnoozeActive) { active in
  73. if active {
  74. showSnoozerBar = true
  75. cancelAutoHide()
  76. } else {
  77. scheduleAutoHide()
  78. }
  79. }
  80. .scaleEffect((isLandscape && barShowing) ? landscapeScale : 1.0, anchor: .top)
  81. .animation(.easeOut(duration: 0.18), value: barShowing)
  82. }
  83. .safeAreaInset(edge: .top) {
  84. if showSnoozerBar || isGlobalSnoozeActive {
  85. snoozerBar(compact: isLandscape)
  86. .transition(.move(edge: .top).combined(with: .opacity))
  87. }
  88. }
  89. .sheet(isPresented: $showDatePickerDate) { datePickerSheetDate() }
  90. .sheet(isPresented: $showDatePickerTime) { datePickerSheetTime() }
  91. }
  92. }
  93. // MARK: - Columns
  94. private func leftColumn(isLandscape: Bool, barShowing: Bool) -> some View {
  95. let topPad: CGFloat = barShowing ? 0 : 16
  96. let bigMaxH: CGFloat = barShowing ? (isLandscape ? 210 : 220) : 240
  97. let dirMaxH: CGFloat = barShowing ? (isLandscape ? 72 : 72) : 80
  98. let deltaMaxH: CGFloat = barShowing ? (isLandscape ? 60 : 60) : 68
  99. let ageMaxH: CGFloat = barShowing ? 36 : 40
  100. return VStack(spacing: 0) {
  101. if !isLandscape && showDisplayName.value {
  102. Text(Bundle.main.displayName)
  103. .font(.system(size: 50, weight: .bold))
  104. .foregroundColor(.white.opacity(0.9))
  105. }
  106. Text(bgText.value)
  107. .font(.system(size: 300, weight: .black))
  108. .minimumScaleFactor(0.5)
  109. .foregroundColor(bgTextColor.value)
  110. .strikethrough(
  111. bgStale.value,
  112. pattern: .solid,
  113. color: bgStale.value ? .red : .clear
  114. )
  115. .frame(maxWidth: .infinity, maxHeight: bigMaxH)
  116. if isLandscape {
  117. HStack(alignment: .firstTextBaseline, spacing: 20) {
  118. Text(directionText.value)
  119. .font(.system(size: 90, weight: .black))
  120. Text(deltaText.value)
  121. .font(.system(size: 70))
  122. }
  123. .minimumScaleFactor(0.5)
  124. .foregroundColor(.white)
  125. .frame(maxWidth: .infinity, maxHeight: dirMaxH)
  126. } else {
  127. Text(directionText.value)
  128. .font(.system(size: 110, weight: .black))
  129. .minimumScaleFactor(0.5)
  130. .foregroundColor(.white)
  131. .frame(maxWidth: .infinity, maxHeight: dirMaxH)
  132. Text(deltaText.value)
  133. .font(.system(size: 70))
  134. .minimumScaleFactor(0.5)
  135. .foregroundColor(.white.opacity(0.8))
  136. .frame(maxWidth: .infinity, maxHeight: deltaMaxH)
  137. }
  138. Text(minAgoText.value)
  139. .font(.system(size: 60))
  140. .minimumScaleFactor(0.5)
  141. .foregroundColor(.white.opacity(0.6))
  142. .frame(maxWidth: .infinity, maxHeight: ageMaxH)
  143. }
  144. .padding(.top, topPad)
  145. .padding(.horizontal, 16)
  146. }
  147. private func rightColumn(isLandscape: Bool) -> some View {
  148. VStack(spacing: 0) {
  149. Spacer()
  150. if showDisplayName.value && isLandscape {
  151. Text(Bundle.main.displayName)
  152. .font(.system(size: 50, weight: .bold))
  153. .foregroundColor(.white.opacity(0.9))
  154. .padding(.bottom, 8)
  155. }
  156. if let alarm = vm.activeAlarm {
  157. VStack(spacing: 16) {
  158. Text(alarm.name)
  159. .font(.system(size: 30, weight: .semibold))
  160. .foregroundColor(.white)
  161. .lineLimit(1)
  162. .minimumScaleFactor(0.5)
  163. .padding(.top, 20)
  164. Divider()
  165. if alarm.type.snoozeTimeUnit != .none {
  166. HStack {
  167. VStack(alignment: .leading, spacing: 4) {
  168. Text("Snooze for")
  169. .font(.headline)
  170. Text("\(vm.snoozeUnits) \(vm.timeUnitLabel)")
  171. .font(.title3).bold()
  172. }
  173. Spacer()
  174. Stepper("", value: $vm.snoozeUnits,
  175. in: alarm.type.snoozeRange,
  176. step: alarm.type.snoozeStep)
  177. .labelsHidden()
  178. }
  179. .padding(.horizontal, 24)
  180. }
  181. Button(action: vm.snoozeTapped) {
  182. Text(vm.snoozeUnits == 0 ? "Acknowledge" : "Snooze")
  183. .font(.system(size: 30, weight: .bold))
  184. .frame(maxWidth: .infinity, minHeight: 60)
  185. .background(Color.orange)
  186. .foregroundColor(.white)
  187. .clipShape(Capsule())
  188. }
  189. .padding(.horizontal, 24)
  190. .padding(.bottom, 20)
  191. }
  192. .background(.ultraThinMaterial)
  193. .cornerRadius(20, corners: [.topLeft, .topRight])
  194. .transition(.move(edge: .bottom).combined(with: .opacity))
  195. .animation(.spring(), value: vm.activeAlarm != nil)
  196. } else {
  197. TimelineView(.periodic(from: .now, by: 1)) { context in
  198. VStack(spacing: 4) {
  199. if snoozerEmoji.value {
  200. Text(bgEmoji)
  201. .font(.system(size: 128))
  202. .minimumScaleFactor(0.5)
  203. }
  204. Text(context.date, format: Date.FormatStyle(date: .omitted, time: .shortened))
  205. .font(.system(size: 70))
  206. .minimumScaleFactor(0.5)
  207. .foregroundColor(.white)
  208. .frame(height: 78)
  209. }
  210. }
  211. Spacer()
  212. }
  213. }
  214. }
  215. private var bgEmoji: String {
  216. guard let bg = bg.value, !bgStale.value else { return "🤷" }
  217. if Localizer.getPreferredUnit() == .millimolesPerLiter,
  218. Localizer.removePeriodAndCommaForBadge(bgText.value) == "55" { return "🦄" }
  219. if Localizer.getPreferredUnit() == .milligramsPerDeciliter, bg == 100 { return "🦄" }
  220. switch bg {
  221. case ..<40: return "❌"
  222. case ..<55: return "🥶"
  223. case ..<73: return "😱"
  224. case ..<98: return "😊"
  225. case ..<102: return "🥇"
  226. case ..<109: return "😎"
  227. case ..<127: return "🥳"
  228. case ..<145: return "🤔"
  229. case ..<163: return "😳"
  230. case ..<181: return "😵‍💫"
  231. case ..<199: return "🎃"
  232. case ..<217: return "🙀"
  233. case ..<235: return "🔥"
  234. case ..<253: return "😬"
  235. case ..<271: return "😡"
  236. case ..<289: return "🤬"
  237. case ..<307: return "🥵"
  238. case ..<325: return "🫣"
  239. case ..<343: return "😩"
  240. case ..<361: return "🤯"
  241. default: return "👿"
  242. }
  243. }
  244. // MARK: - Snoozer Bar
  245. private func snoozerBar(compact: Bool) -> some View {
  246. let active = isGlobalSnoozeActive
  247. let until = cfgStore.value.snoozeUntil
  248. let vPad: CGFloat = compact ? 6 : 10
  249. let controlH: CGFloat = compact ? 40 : 44
  250. let primaryH: CGFloat = compact ? 48 : 54
  251. let primaryMinW: CGFloat = compact ? 210 : 230
  252. return VStack(spacing: compact ? 6 : 10) {
  253. if active {
  254. if compact {
  255. HStack(spacing: 10) {
  256. Image(systemName: "bell.slash.fill")
  257. .font(.system(size: 22, weight: .bold))
  258. .foregroundColor(.red)
  259. Text("All alerts snoozed")
  260. .font(.headline)
  261. .foregroundColor(.white)
  262. .lineLimit(1)
  263. .minimumScaleFactor(0.8)
  264. .layoutPriority(1)
  265. Spacer(minLength: 6)
  266. Button(action: { showDatePickerDate = true }) {
  267. HStack(spacing: 6) {
  268. Image(systemName: "calendar").font(.system(size: 12, weight: .semibold))
  269. Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .abbreviated, time: .omitted))
  270. .font(.footnote)
  271. }
  272. .foregroundColor(.white.opacity(0.9))
  273. .padding(.vertical, 6).padding(.horizontal, 10)
  274. .background(Color.white.opacity(0.08))
  275. .clipShape(Capsule())
  276. }.buttonStyle(.plain)
  277. Button(action: { showDatePickerTime = true }) {
  278. HStack(spacing: 6) {
  279. Image(systemName: "clock").font(.system(size: 12, weight: .semibold))
  280. Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .omitted, time: .shortened))
  281. .font(.footnote)
  282. }
  283. .foregroundColor(.white.opacity(0.9))
  284. .padding(.vertical, 6).padding(.horizontal, 10)
  285. .background(Color.white.opacity(0.08))
  286. .clipShape(Capsule())
  287. }.buttonStyle(.plain)
  288. Button(action: { adjustSnooze(byMinutes: -30) }) {
  289. Text("− 30m").bold()
  290. .frame(minWidth: 76, minHeight: controlH)
  291. .background(Color.white.opacity(0.12))
  292. .foregroundColor(.white)
  293. .clipShape(Capsule())
  294. }.buttonStyle(.plain)
  295. Button(action: { adjustSnooze(byMinutes: +30) }) {
  296. Text("+ 30m").bold()
  297. .frame(minWidth: 76, minHeight: controlH)
  298. .background(Color.white.opacity(0.12))
  299. .foregroundColor(.white)
  300. .clipShape(Capsule())
  301. }.buttonStyle(.plain)
  302. Button(role: .destructive, action: { endSnooze() }) {
  303. Text("End now").bold()
  304. .frame(minWidth: 96, minHeight: controlH)
  305. .background(Color.red.opacity(0.6))
  306. .foregroundColor(.white)
  307. .clipShape(Capsule())
  308. }.buttonStyle(.plain)
  309. Image(systemName: phaseIconName())
  310. .font(.system(size: 18, weight: .semibold))
  311. .foregroundColor(.white.opacity(0.9))
  312. }
  313. .padding(.bottom, 2)
  314. } else {
  315. HStack(alignment: .center, spacing: 14) {
  316. Image(systemName: "bell.slash.fill")
  317. .font(.system(size: 24, weight: .bold))
  318. .foregroundColor(.red)
  319. VStack(alignment: .leading, spacing: 2) {
  320. Text("All alerts snoozed")
  321. .font(.headline)
  322. .foregroundColor(.white)
  323. HStack(spacing: 8) {
  324. Button(action: { showDatePickerDate = true }) {
  325. HStack(spacing: 6) {
  326. Image(systemName: "calendar").font(.system(size: 12, weight: .semibold))
  327. Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .abbreviated, time: .omitted))
  328. .font(.subheadline)
  329. }
  330. .foregroundColor(.white.opacity(0.9))
  331. .padding(.vertical, 6).padding(.horizontal, 10)
  332. .background(Color.white.opacity(0.08))
  333. .clipShape(Capsule())
  334. }.buttonStyle(.plain)
  335. Button(action: { showDatePickerTime = true }) {
  336. HStack(spacing: 6) {
  337. Image(systemName: "clock").font(.system(size: 12, weight: .semibold))
  338. Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .omitted, time: .shortened))
  339. .font(.subheadline)
  340. }
  341. .foregroundColor(.white.opacity(0.9))
  342. .padding(.vertical, 6).padding(.horizontal, 10)
  343. .background(Color.white.opacity(0.08))
  344. .clipShape(Capsule())
  345. }.buttonStyle(.plain)
  346. }
  347. }
  348. Spacer()
  349. Image(systemName: phaseIconName())
  350. .font(.system(size: 18, weight: .semibold))
  351. .foregroundColor(.white.opacity(0.9))
  352. }
  353. HStack(spacing: 12) {
  354. Button(action: { adjustSnooze(byMinutes: -30) }) {
  355. Text("− 30m").font(.title3).bold()
  356. .frame(minWidth: 90, minHeight: 44)
  357. .background(Color.white.opacity(0.12))
  358. .foregroundColor(.white)
  359. .clipShape(Capsule())
  360. }.buttonStyle(.plain)
  361. Button(action: { adjustSnooze(byMinutes: +30) }) {
  362. Text("+ 30m").font(.title3).bold()
  363. .frame(minWidth: 90, minHeight: 44)
  364. .background(Color.white.opacity(0.12))
  365. .foregroundColor(.white)
  366. .clipShape(Capsule())
  367. }.buttonStyle(.plain)
  368. Button(role: .destructive, action: { endSnooze() }) {
  369. Text("End now").font(.title3).bold()
  370. .frame(minWidth: 110, minHeight: 44)
  371. .background(Color.red.opacity(0.6))
  372. .foregroundColor(.white)
  373. .clipShape(Capsule())
  374. }.buttonStyle(.plain)
  375. }
  376. .padding(.bottom, 8)
  377. }
  378. } else {
  379. HStack(spacing: 12) {
  380. Button(action: { activateSnooze1h() }) {
  381. HStack(spacing: 10) {
  382. Image(systemName: "bell.slash")
  383. Text("Snooze all · 1h").bold()
  384. .lineLimit(1)
  385. .minimumScaleFactor(0.9)
  386. }
  387. .font(.title3)
  388. .frame(minWidth: primaryMinW, minHeight: primaryH)
  389. .padding(.horizontal, 6)
  390. .background(Color.orange)
  391. .foregroundColor(.white)
  392. .clipShape(Capsule())
  393. .overlay(Capsule().stroke(Color.white.opacity(0.15), lineWidth: 1))
  394. .shadow(radius: 3)
  395. }.buttonStyle(.plain)
  396. Spacer()
  397. Image(systemName: phaseIconName())
  398. .font(.system(size: 18, weight: .semibold))
  399. .foregroundColor(.white.opacity(0.9))
  400. }
  401. .padding(.bottom, compact ? 6 : 8)
  402. }
  403. }
  404. .padding(.horizontal, 16)
  405. .padding(.vertical, vPad)
  406. .background(
  407. Color.white.opacity(0.08)
  408. .cornerRadius(18, corners: [.bottomLeft, .bottomRight])
  409. )
  410. .onTapGesture { resetAutoHide() }
  411. }
  412. // MARK: - Snoozer Bar helpers
  413. private func presentSnoozerBar() {
  414. showSnoozerBar = true
  415. if isGlobalSnoozeActive || vm.activeAlarm != nil {
  416. cancelAutoHide()
  417. } else {
  418. scheduleAutoHide()
  419. }
  420. }
  421. private func cancelAutoHide() {
  422. autoHideTask?.cancel()
  423. autoHideTask = nil
  424. }
  425. private func scheduleAutoHide() {
  426. cancelAutoHide()
  427. if isGlobalSnoozeActive || vm.activeAlarm != nil { return }
  428. let task = DispatchWorkItem {
  429. if !isGlobalSnoozeActive && vm.activeAlarm == nil {
  430. withAnimation { showSnoozerBar = false }
  431. }
  432. }
  433. autoHideTask = task
  434. DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: task)
  435. }
  436. private func resetAutoHide() {
  437. if !isGlobalSnoozeActive, vm.activeAlarm == nil {
  438. scheduleAutoHide()
  439. } else {
  440. cancelAutoHide()
  441. }
  442. }
  443. private func activateSnooze1h() {
  444. if vm.activeAlarm != nil {
  445. vm.snoozeTapped()
  446. }
  447. cfgStore.value.snoozeUntil = Date().addingTimeInterval(3600)
  448. showSnoozerBar = true
  449. cancelAutoHide()
  450. }
  451. private func endSnooze() {
  452. cfgStore.value.snoozeUntil = nil
  453. if vm.activeAlarm == nil {
  454. scheduleAutoHide()
  455. } else {
  456. cancelAutoHide()
  457. }
  458. }
  459. private func adjustSnooze(byMinutes delta: Int) {
  460. guard let current = cfgStore.value.snoozeUntil else { return }
  461. let newDate = current.addingTimeInterval(TimeInterval(delta * 60))
  462. if newDate <= Date() { endSnooze() } else { cfgStore.value.snoozeUntil = newDate }
  463. }
  464. private func snoozeUntilBindingForDate() -> Binding<Date> {
  465. Binding<Date>(
  466. get: { cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600) },
  467. set: { newDateOnly in
  468. let base = cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600)
  469. let cal = Calendar.current
  470. let time = cal.dateComponents([.hour, .minute, .second], from: base)
  471. var comps = cal.dateComponents([.year, .month, .day], from: newDateOnly)
  472. comps.hour = time.hour; comps.minute = time.minute; comps.second = time.second
  473. cfgStore.value.snoozeUntil = cal.date(from: comps) ?? newDateOnly
  474. }
  475. )
  476. }
  477. private func snoozeUntilBindingForTime() -> Binding<Date> {
  478. Binding<Date>(
  479. get: { cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600) },
  480. set: { newTimeOnly in
  481. let base = cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600)
  482. let cal = Calendar.current
  483. var comps = cal.dateComponents([.year, .month, .day], from: base)
  484. let time = cal.dateComponents([.hour, .minute, .second], from: newTimeOnly)
  485. comps.hour = time.hour; comps.minute = time.minute; comps.second = time.second
  486. cfgStore.value.snoozeUntil = cal.date(from: comps) ?? newTimeOnly
  487. }
  488. )
  489. }
  490. private func phaseIconName() -> String {
  491. let now = Date()
  492. let cal = Calendar.current
  493. let comps = cal.dateComponents([.year, .month, .day], from: now)
  494. func time(_ t: TimeOfDay) -> Date {
  495. var c = comps
  496. c.hour = t.hour
  497. c.minute = t.minute
  498. return cal.date(from: c) ?? now
  499. }
  500. let dayStart = time(cfgStore.value.dayStart)
  501. let nightStart = time(cfgStore.value.nightStart)
  502. let isNight: Bool
  503. if dayStart <= nightStart {
  504. if now >= nightStart { isNight = true }
  505. else if now >= dayStart { isNight = false } else { isNight = true }
  506. } else { // crosses midnight
  507. if now >= dayStart { isNight = false }
  508. else if now >= nightStart { isNight = true } else { isNight = false }
  509. }
  510. return isNight ? "moon.fill" : "sun.max.fill"
  511. }
  512. // MARK: - Sheets
  513. private func datePickerSheetDate() -> some View {
  514. NavigationView {
  515. VStack {
  516. DatePicker(
  517. "Snooze until (date)",
  518. selection: snoozeUntilBindingForDate(),
  519. displayedComponents: [.date]
  520. )
  521. .datePickerStyle(.graphical)
  522. .padding()
  523. Spacer()
  524. }
  525. .navigationTitle("Snooze Date")
  526. .toolbar {
  527. ToolbarItem(placement: .confirmationAction) {
  528. Button("Done") { showDatePickerDate = false }
  529. }
  530. }
  531. }
  532. .onAppear { cancelAutoHide() }
  533. .onDisappear { if !isGlobalSnoozeActive { scheduleAutoHide() } }
  534. }
  535. private func datePickerSheetTime() -> some View {
  536. NavigationView {
  537. VStack {
  538. DatePicker(
  539. "Snooze until (time)",
  540. selection: snoozeUntilBindingForTime(),
  541. displayedComponents: [.hourAndMinute]
  542. )
  543. .datePickerStyle(.wheel)
  544. .labelsHidden()
  545. .padding()
  546. Spacer()
  547. }
  548. .navigationTitle("Snooze Time")
  549. .toolbar {
  550. ToolbarItem(placement: .confirmationAction) {
  551. Button("Done") { showDatePickerTime = false }
  552. }
  553. }
  554. }
  555. .onAppear { cancelAutoHide() }
  556. .onDisappear { if !isGlobalSnoozeActive { scheduleAutoHide() } }
  557. }
  558. }
  559. private extension View {
  560. func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
  561. clipShape(RoundedCorner(radius: radius, corners: corners))
  562. }
  563. }
  564. private struct RoundedCorner: Shape {
  565. var radius: CGFloat = .infinity
  566. var corners: UIRectCorner = .allCorners
  567. func path(in rect: CGRect) -> Path {
  568. let path = UIBezierPath(
  569. roundedRect: rect,
  570. byRoundingCorners: corners,
  571. cornerRadii: CGSize(width: radius, height: radius)
  572. )
  573. return Path(path.cgPath)
  574. }
  575. }