SnoozerView.swift 8.5 KB

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