SnoozerView.swift 26 KB

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