SnoozerView.swift 27 KB

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