SnoozerView.swift 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. // LoopFollow
  2. // SnoozerView.swift
  3. // Created by Jonas Björkert.
  4. import SwiftUI
  5. struct SnoozerView: View {
  6. @StateObject private var vm = SnoozerViewModel()
  7. @ObservedObject var showDisplayName = Storage.shared.showDisplayName
  8. @ObservedObject var minAgoText = Observable.shared.minAgoText
  9. @ObservedObject var bgText = Observable.shared.bgText
  10. @ObservedObject var bgTextColor = Observable.shared.bgTextColor
  11. @ObservedObject var directionText = Observable.shared.directionText
  12. @ObservedObject var deltaText = Observable.shared.deltaText
  13. @ObservedObject var bgStale = Observable.shared.bgStale
  14. @ObservedObject var bg = Observable.shared.bg
  15. @ObservedObject var snoozerEmoji = Storage.shared.snoozerEmoji
  16. var body: some View {
  17. GeometryReader { geo in
  18. ZStack {
  19. Color.black
  20. .edgesIgnoringSafeArea(.all)
  21. let isLandscape = geo.size.width > geo.size.height
  22. Group {
  23. if isLandscape {
  24. HStack(spacing: 0) {
  25. leftColumn(isLandscape: true)
  26. rightColumn
  27. }
  28. } else {
  29. VStack(spacing: 0) {
  30. leftColumn(isLandscape: false)
  31. rightColumn
  32. }
  33. }
  34. }
  35. .frame(width: geo.size.width, height: geo.size.height)
  36. }
  37. }
  38. }
  39. // MARK: - Left Column (BG / Direction / Delta / Age)
  40. private func leftColumn(isLandscape: Bool) -> some View {
  41. VStack(spacing: 0) {
  42. Text(bgText.value)
  43. .font(.system(size: 300, weight: .black))
  44. .minimumScaleFactor(0.5)
  45. .foregroundColor(bgTextColor.value)
  46. .strikethrough(
  47. bgStale.value,
  48. pattern: .solid,
  49. color: bgStale.value ? .red : .clear
  50. )
  51. .frame(maxWidth: .infinity, maxHeight: 240)
  52. if isLandscape {
  53. HStack(alignment: .firstTextBaseline, spacing: 20) {
  54. Text(directionText.value)
  55. .font(.system(size: 90, weight: .black))
  56. Text(deltaText.value)
  57. .font(.system(size: 70))
  58. }
  59. .minimumScaleFactor(0.5)
  60. .foregroundColor(.white)
  61. .frame(maxWidth: .infinity, maxHeight: 80)
  62. } else {
  63. Text(directionText.value)
  64. .font(.system(size: 110, weight: .black))
  65. .minimumScaleFactor(0.5)
  66. .foregroundColor(.white)
  67. .frame(maxWidth: .infinity, maxHeight: 80)
  68. Text(deltaText.value)
  69. .font(.system(size: 70))
  70. .minimumScaleFactor(0.5)
  71. .foregroundColor(.white.opacity(0.8))
  72. .frame(maxWidth: .infinity, maxHeight: 68)
  73. }
  74. Text(minAgoText.value)
  75. .font(.system(size: 60))
  76. .minimumScaleFactor(0.5)
  77. .foregroundColor(.white.opacity(0.6))
  78. .frame(maxWidth: .infinity, maxHeight: 40)
  79. }
  80. .padding(.top, 16)
  81. .padding(.horizontal, 16)
  82. }
  83. // MARK: - Right Column (Clock/Alert + Snooze Controls)
  84. private var rightColumn: some View {
  85. VStack(spacing: 0) {
  86. Spacer()
  87. if showDisplayName.value {
  88. Text(Bundle.main.displayName)
  89. .font(.title2.weight(.semibold))
  90. .foregroundColor(.white.opacity(0.8))
  91. .padding(.bottom, 4)
  92. }
  93. if let alarm = vm.activeAlarm {
  94. VStack(spacing: 16) {
  95. Text(alarm.name)
  96. .font(.system(size: 30, weight: .semibold))
  97. .foregroundColor(.white)
  98. .lineLimit(1)
  99. .minimumScaleFactor(0.5)
  100. .padding(.top, 20)
  101. Divider()
  102. // snooze controls
  103. if alarm.type.snoozeTimeUnit != .none {
  104. HStack {
  105. VStack(alignment: .leading, spacing: 4) {
  106. Text("Snooze for")
  107. .font(.headline)
  108. Text("\(vm.snoozeUnits) \(vm.timeUnitLabel)")
  109. .font(.title3).bold()
  110. }
  111. Spacer()
  112. Stepper("", value: $vm.snoozeUnits,
  113. in: alarm.type.snoozeRange,
  114. step: alarm.type.snoozeStep)
  115. .labelsHidden()
  116. }
  117. .padding(.horizontal, 24)
  118. }
  119. Button(action: vm.snoozeTapped) {
  120. Text(vm.snoozeUnits == 0 ? "Acknowledge" : "Snooze")
  121. .font(.title2).bold()
  122. .frame(maxWidth: .infinity, minHeight: 50)
  123. .background(Color.accentColor)
  124. .foregroundColor(.white)
  125. .cornerRadius(12)
  126. }
  127. .padding(.horizontal, 24)
  128. .padding(.bottom, 20)
  129. }
  130. .background(.ultraThinMaterial)
  131. .cornerRadius(20, corners: [.topLeft, .topRight])
  132. .transition(.move(edge: .bottom).combined(with: .opacity))
  133. .animation(.spring(), value: vm.activeAlarm != nil)
  134. } else {
  135. TimelineView(.periodic(from: .now, by: 1)) { context in
  136. VStack(spacing: 4) {
  137. if snoozerEmoji.value {
  138. Text(bgEmoji)
  139. .font(.system(size: 128))
  140. .minimumScaleFactor(0.5)
  141. }
  142. Text(context.date, format: Date.FormatStyle(date: .omitted, time: .shortened))
  143. .font(.system(size: 70))
  144. .minimumScaleFactor(0.5)
  145. .foregroundColor(.white)
  146. .frame(height: 78)
  147. }
  148. }
  149. Spacer()
  150. }
  151. }
  152. }
  153. private var bgEmoji: String {
  154. guard let bg = bg.value, !bgStale.value else {
  155. return "🤷"
  156. }
  157. if Localizer.getPreferredUnit() == .millimolesPerLiter, Localizer.removePeriodAndCommaForBadge(bgText.value) == "55" {
  158. return "🦄"
  159. }
  160. if Localizer.getPreferredUnit() == .milligramsPerDeciliter, bg == 100 {
  161. return "🦄"
  162. }
  163. switch bg {
  164. case ..<40: return "❌"
  165. case ..<55: return "🥶"
  166. case ..<73: return "😱"
  167. case ..<98: return "😊"
  168. case ..<102: return "🥇"
  169. case ..<109: return "😎"
  170. case ..<127: return "🥳"
  171. case ..<145: return "🤔"
  172. case ..<163: return "😳"
  173. case ..<181: return "😵‍💫"
  174. case ..<199: return "🎃"
  175. case ..<217: return "🙀"
  176. case ..<235: return "🔥"
  177. case ..<253: return "😬"
  178. case ..<271: return "😡"
  179. case ..<289: return "🤬"
  180. case ..<307: return "🥵"
  181. case ..<325: return "🫣"
  182. case ..<343: return "😩"
  183. case ..<361: return "🤯"
  184. default: return "👿"
  185. }
  186. }
  187. }
  188. private extension View {
  189. func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
  190. clipShape(RoundedCorner(radius: radius, corners: corners))
  191. }
  192. }
  193. private struct RoundedCorner: Shape {
  194. var radius: CGFloat = .infinity
  195. var corners: UIRectCorner = .allCorners
  196. func path(in rect: CGRect) -> Path {
  197. let path = UIBezierPath(
  198. roundedRect: rect,
  199. byRoundingCorners: corners,
  200. cornerRadii: CGSize(width: radius, height: radius)
  201. )
  202. return Path(path.cgPath)
  203. }
  204. }