diff --git a/LoopFollow/Snoozer/SnoozerView.swift b/LoopFollow/Snoozer/SnoozerView.swift index a9e02a96e..1e78ba1cc 100644 --- a/LoopFollow/Snoozer/SnoozerView.swift +++ b/LoopFollow/Snoozer/SnoozerView.swift @@ -16,36 +16,100 @@ struct SnoozerView: View { @ObservedObject var bg = Observable.shared.bg @ObservedObject var snoozerEmoji = Storage.shared.snoozerEmoji + @ObservedObject private var cfgStore = Storage.shared.alarmConfiguration + + // Snoozer Bar state + @State private var showSnoozerBar: Bool = false + @State private var showDatePickerDate: Bool = false + @State private var showDatePickerTime: Bool = false + @State private var autoHideTask: DispatchWorkItem? = nil + @State private var lastActiveState: Bool = false + + private var isGlobalSnoozeActive: Bool { + if let until = cfgStore.value.snoozeUntil { return until > Date() } + return false + } + var body: some View { GeometryReader { geo in - ZStack { - Color.black - .edgesIgnoringSafeArea(.all) + let isLandscape = geo.size.width > geo.size.height + let barShowing = showSnoozerBar || isGlobalSnoozeActive + let landscapeScale: CGFloat = 0.8 - let isLandscape = geo.size.width > geo.size.height + ZStack { + Color.black.ignoresSafeArea() - Group { + VStack(spacing: 0) { if isLandscape { HStack(spacing: 0) { - leftColumn(isLandscape: true) + leftColumn(isLandscape: true, barShowing: barShowing) rightColumn(isLandscape: true) } } else { VStack(spacing: 0) { - leftColumn(isLandscape: false) + leftColumn(isLandscape: false, barShowing: barShowing) rightColumn(isLandscape: false) } } } - .frame(width: geo.size.width, height: geo.size.height) + .contentShape(Rectangle()) + .onTapGesture { presentSnoozerBar() } + .onAppear { + presentSnoozerBar() + lastActiveState = isGlobalSnoozeActive + } + .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in + let active = isGlobalSnoozeActive + if lastActiveState != active { + lastActiveState = active + if active { + showSnoozerBar = true + cancelAutoHide() + } else { + scheduleAutoHide() + } + } + } + .onReceive(vm.$activeAlarm) { alarm in + if alarm != nil { + showSnoozerBar = true + cancelAutoHide() + } else if !isGlobalSnoozeActive { + scheduleAutoHide() + } + } + .onChange(of: isGlobalSnoozeActive) { active in + if active { + showSnoozerBar = true + cancelAutoHide() + } else { + scheduleAutoHide() + } + } + .scaleEffect((isLandscape && barShowing) ? landscapeScale : 1.0, anchor: .top) + .animation(.easeOut(duration: 0.18), value: barShowing) } + .safeAreaInset(edge: .top) { + if showSnoozerBar || isGlobalSnoozeActive { + snoozerBar(compact: isLandscape) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .sheet(isPresented: $showDatePickerDate) { datePickerSheetDate() } + .sheet(isPresented: $showDatePickerTime) { datePickerSheetTime() } } } - // MARK: - Left Column (BG / Direction / Delta / Age) + // MARK: - Columns - private func leftColumn(isLandscape: Bool) -> some View { - VStack(spacing: 0) { + private func leftColumn(isLandscape: Bool, barShowing: Bool) -> some View { + let topPad: CGFloat = barShowing ? 0 : 16 + let bigMaxH: CGFloat = barShowing ? (isLandscape ? 210 : 220) : 240 + let dirMaxH: CGFloat = barShowing ? (isLandscape ? 72 : 72) : 80 + let deltaMaxH: CGFloat = barShowing ? (isLandscape ? 60 : 60) : 68 + let ageMaxH: CGFloat = barShowing ? 36 : 40 + + return VStack(spacing: 0) { if !isLandscape && showDisplayName.value { Text(Bundle.main.displayName) .font(.system(size: 50, weight: .bold)) @@ -61,46 +125,42 @@ struct SnoozerView: View { pattern: .solid, color: bgStale.value ? .red : .clear ) - .frame(maxWidth: .infinity, maxHeight: 240) + .frame(maxWidth: .infinity, maxHeight: bigMaxH) if isLandscape { HStack(alignment: .firstTextBaseline, spacing: 20) { Text(directionText.value) .font(.system(size: 90, weight: .black)) - Text(deltaText.value) .font(.system(size: 70)) } .minimumScaleFactor(0.5) .foregroundColor(.white) - .frame(maxWidth: .infinity, maxHeight: 80) - + .frame(maxWidth: .infinity, maxHeight: dirMaxH) } else { Text(directionText.value) .font(.system(size: 110, weight: .black)) .minimumScaleFactor(0.5) .foregroundColor(.white) - .frame(maxWidth: .infinity, maxHeight: 80) + .frame(maxWidth: .infinity, maxHeight: dirMaxH) Text(deltaText.value) .font(.system(size: 70)) .minimumScaleFactor(0.5) .foregroundColor(.white.opacity(0.8)) - .frame(maxWidth: .infinity, maxHeight: 68) + .frame(maxWidth: .infinity, maxHeight: deltaMaxH) } Text(minAgoText.value) .font(.system(size: 60)) .minimumScaleFactor(0.5) .foregroundColor(.white.opacity(0.6)) - .frame(maxWidth: .infinity, maxHeight: 40) + .frame(maxWidth: .infinity, maxHeight: ageMaxH) } - .padding(.top, 16) + .padding(.top, topPad) .padding(.horizontal, 16) } - // MARK: - Right Column (Clock/Alert + Snooze Controls) - private func rightColumn(isLandscape: Bool) -> some View { VStack(spacing: 0) { Spacer() @@ -121,7 +181,6 @@ struct SnoozerView: View { .padding(.top, 20) Divider() - // snooze controls if alarm.type.snoozeTimeUnit != .none { HStack { VStack(alignment: .leading, spacing: 4) { @@ -176,18 +235,10 @@ struct SnoozerView: View { } private var bgEmoji: String { - guard let bg = bg.value, !bgStale.value else { - return "๐Ÿคท" - } - - if Localizer.getPreferredUnit() == .millimolesPerLiter, Localizer.removePeriodAndCommaForBadge(bgText.value) == "55" { - return "๐Ÿฆ„" - } - - if Localizer.getPreferredUnit() == .milligramsPerDeciliter, bg == 100 { - return "๐Ÿฆ„" - } - + guard let bg = bg.value, !bgStale.value else { return "๐Ÿคท" } + if Localizer.getPreferredUnit() == .millimolesPerLiter, + Localizer.removePeriodAndCommaForBadge(bgText.value) == "55" { return "๐Ÿฆ„" } + if Localizer.getPreferredUnit() == .milligramsPerDeciliter, bg == 100 { return "๐Ÿฆ„" } switch bg { case ..<40: return "โŒ" case ..<55: return "๐Ÿฅถ" @@ -212,6 +263,358 @@ struct SnoozerView: View { default: return "๐Ÿ‘ฟ" } } + + // MARK: - Snoozer Bar + + private func snoozerBar(compact: Bool) -> some View { + let active = isGlobalSnoozeActive + let until = cfgStore.value.snoozeUntil + let vPad: CGFloat = compact ? 6 : 10 + let controlH: CGFloat = compact ? 40 : 44 + let primaryH: CGFloat = compact ? 48 : 54 + let primaryMinW: CGFloat = compact ? 210 : 230 + + return VStack(spacing: compact ? 6 : 10) { + if active { + if compact { + HStack(spacing: 10) { + Image(systemName: "bell.slash.fill") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.red) + + Text("All alerts snoozed") + .font(.headline) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + .layoutPriority(1) + + Spacer(minLength: 6) + + Button(action: { showDatePickerDate = true }) { + HStack(spacing: 6) { + Image(systemName: "calendar").font(.system(size: 12, weight: .semibold)) + Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .abbreviated, time: .omitted)) + .font(.footnote) + } + .foregroundColor(.white.opacity(0.9)) + .padding(.vertical, 6).padding(.horizontal, 10) + .background(Color.white.opacity(0.08)) + .clipShape(Capsule()) + }.buttonStyle(.plain) + + Button(action: { showDatePickerTime = true }) { + HStack(spacing: 6) { + Image(systemName: "clock").font(.system(size: 12, weight: .semibold)) + Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .omitted, time: .shortened)) + .font(.footnote) + } + .foregroundColor(.white.opacity(0.9)) + .padding(.vertical, 6).padding(.horizontal, 10) + .background(Color.white.opacity(0.08)) + .clipShape(Capsule()) + }.buttonStyle(.plain) + + Button(action: { adjustSnooze(byMinutes: -30) }) { + Text("โˆ’ 30m").bold() + .frame(minWidth: 76, minHeight: controlH) + .background(Color.white.opacity(0.12)) + .foregroundColor(.white) + .clipShape(Capsule()) + }.buttonStyle(.plain) + + Button(action: { adjustSnooze(byMinutes: +30) }) { + Text("+ 30m").bold() + .frame(minWidth: 76, minHeight: controlH) + .background(Color.white.opacity(0.12)) + .foregroundColor(.white) + .clipShape(Capsule()) + }.buttonStyle(.plain) + + Button(role: .destructive, action: { endSnooze() }) { + Text("End now").bold() + .frame(minWidth: 96, minHeight: controlH) + .background(Color.red.opacity(0.6)) + .foregroundColor(.white) + .clipShape(Capsule()) + }.buttonStyle(.plain) + + Image(systemName: phaseIconName()) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white.opacity(0.9)) + } + .padding(.bottom, 2) + } else { + HStack(alignment: .center, spacing: 14) { + Image(systemName: "bell.slash.fill") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.red) + + VStack(alignment: .leading, spacing: 2) { + Text("All alerts snoozed") + .font(.headline) + .foregroundColor(.white) + + HStack(spacing: 8) { + Button(action: { showDatePickerDate = true }) { + HStack(spacing: 6) { + Image(systemName: "calendar").font(.system(size: 12, weight: .semibold)) + Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .abbreviated, time: .omitted)) + .font(.subheadline) + } + .foregroundColor(.white.opacity(0.9)) + .padding(.vertical, 6).padding(.horizontal, 10) + .background(Color.white.opacity(0.08)) + .clipShape(Capsule()) + }.buttonStyle(.plain) + + Button(action: { showDatePickerTime = true }) { + HStack(spacing: 6) { + Image(systemName: "clock").font(.system(size: 12, weight: .semibold)) + Text((until ?? Date().addingTimeInterval(3600)).formatted(date: .omitted, time: .shortened)) + .font(.subheadline) + } + .foregroundColor(.white.opacity(0.9)) + .padding(.vertical, 6).padding(.horizontal, 10) + .background(Color.white.opacity(0.08)) + .clipShape(Capsule()) + }.buttonStyle(.plain) + } + } + + Spacer() + + Image(systemName: phaseIconName()) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white.opacity(0.9)) + } + + HStack(spacing: 12) { + Button(action: { adjustSnooze(byMinutes: -30) }) { + Text("โˆ’ 30m").font(.title3).bold() + .frame(minWidth: 90, minHeight: 44) + .background(Color.white.opacity(0.12)) + .foregroundColor(.white) + .clipShape(Capsule()) + }.buttonStyle(.plain) + + Button(action: { adjustSnooze(byMinutes: +30) }) { + Text("+ 30m").font(.title3).bold() + .frame(minWidth: 90, minHeight: 44) + .background(Color.white.opacity(0.12)) + .foregroundColor(.white) + .clipShape(Capsule()) + }.buttonStyle(.plain) + + Button(role: .destructive, action: { endSnooze() }) { + Text("End now").font(.title3).bold() + .frame(minWidth: 110, minHeight: 44) + .background(Color.red.opacity(0.6)) + .foregroundColor(.white) + .clipShape(Capsule()) + }.buttonStyle(.plain) + } + .padding(.bottom, 8) + } + } else { + HStack(spacing: 12) { + Button(action: { activateSnooze1h() }) { + HStack(spacing: 10) { + Image(systemName: "bell.slash") + Text("Snooze all ยท 1h").bold() + .lineLimit(1) + .minimumScaleFactor(0.9) + } + .font(.title3) + .frame(minWidth: primaryMinW, minHeight: primaryH) + .padding(.horizontal, 6) + .background(Color.orange) + .foregroundColor(.white) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color.white.opacity(0.15), lineWidth: 1)) + .shadow(radius: 3) + }.buttonStyle(.plain) + + Spacer() + + Image(systemName: phaseIconName()) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white.opacity(0.9)) + } + .padding(.bottom, compact ? 6 : 8) + } + } + .padding(.horizontal, 16) + .padding(.vertical, vPad) + .background( + Color.white.opacity(0.08) + .cornerRadius(18, corners: [.bottomLeft, .bottomRight]) + ) + .onTapGesture { resetAutoHide() } + } + + // MARK: - Snoozer Bar helpers + + private func presentSnoozerBar() { + showSnoozerBar = true + if isGlobalSnoozeActive || vm.activeAlarm != nil { + cancelAutoHide() + } else { + scheduleAutoHide() + } + } + + private func cancelAutoHide() { + autoHideTask?.cancel() + autoHideTask = nil + } + + private func scheduleAutoHide() { + cancelAutoHide() + if isGlobalSnoozeActive || vm.activeAlarm != nil { return } + let task = DispatchWorkItem { + if !isGlobalSnoozeActive && vm.activeAlarm == nil { + withAnimation { showSnoozerBar = false } + } + } + autoHideTask = task + DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: task) + } + + private func resetAutoHide() { + if !isGlobalSnoozeActive, vm.activeAlarm == nil { + scheduleAutoHide() + } else { + cancelAutoHide() + } + } + + private func activateSnooze1h() { + if vm.activeAlarm != nil { + vm.snoozeTapped() + } + + cfgStore.value.snoozeUntil = Date().addingTimeInterval(3600) + + showSnoozerBar = true + cancelAutoHide() + } + + private func endSnooze() { + cfgStore.value.snoozeUntil = nil + if vm.activeAlarm == nil { + scheduleAutoHide() + } else { + cancelAutoHide() + } + } + + private func adjustSnooze(byMinutes delta: Int) { + guard let current = cfgStore.value.snoozeUntil else { return } + let newDate = current.addingTimeInterval(TimeInterval(delta * 60)) + if newDate <= Date() { endSnooze() } else { cfgStore.value.snoozeUntil = newDate } + } + + private func snoozeUntilBindingForDate() -> Binding { + Binding( + get: { cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600) }, + set: { newDateOnly in + let base = cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600) + let cal = Calendar.current + let time = cal.dateComponents([.hour, .minute, .second], from: base) + var comps = cal.dateComponents([.year, .month, .day], from: newDateOnly) + comps.hour = time.hour; comps.minute = time.minute; comps.second = time.second + cfgStore.value.snoozeUntil = cal.date(from: comps) ?? newDateOnly + } + ) + } + + private func snoozeUntilBindingForTime() -> Binding { + Binding( + get: { cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600) }, + set: { newTimeOnly in + let base = cfgStore.value.snoozeUntil ?? Date().addingTimeInterval(3600) + let cal = Calendar.current + var comps = cal.dateComponents([.year, .month, .day], from: base) + let time = cal.dateComponents([.hour, .minute, .second], from: newTimeOnly) + comps.hour = time.hour; comps.minute = time.minute; comps.second = time.second + cfgStore.value.snoozeUntil = cal.date(from: comps) ?? newTimeOnly + } + ) + } + + private func phaseIconName() -> String { + let now = Date() + let cal = Calendar.current + let comps = cal.dateComponents([.year, .month, .day], from: now) + func time(_ t: TimeOfDay) -> Date { + var c = comps + c.hour = t.hour + c.minute = t.minute + return cal.date(from: c) ?? now + } + let dayStart = time(cfgStore.value.dayStart) + let nightStart = time(cfgStore.value.nightStart) + + let isNight: Bool + if dayStart <= nightStart { + if now >= nightStart { isNight = true } + else if now >= dayStart { isNight = false } else { isNight = true } + } else { // crosses midnight + if now >= dayStart { isNight = false } + else if now >= nightStart { isNight = true } else { isNight = false } + } + return isNight ? "moon.fill" : "sun.max.fill" + } + + // MARK: - Sheets + + private func datePickerSheetDate() -> some View { + NavigationView { + VStack { + DatePicker( + "Snooze until (date)", + selection: snoozeUntilBindingForDate(), + displayedComponents: [.date] + ) + .datePickerStyle(.graphical) + .padding() + Spacer() + } + .navigationTitle("Snooze Date") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showDatePickerDate = false } + } + } + } + .onAppear { cancelAutoHide() } + .onDisappear { if !isGlobalSnoozeActive { scheduleAutoHide() } } + } + + private func datePickerSheetTime() -> some View { + NavigationView { + VStack { + DatePicker( + "Snooze until (time)", + selection: snoozeUntilBindingForTime(), + displayedComponents: [.hourAndMinute] + ) + .datePickerStyle(.wheel) + .labelsHidden() + .padding() + Spacer() + } + .navigationTitle("Snooze Time") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showDatePickerTime = false } + } + } + } + .onAppear { cancelAutoHide() } + .onDisappear { if !isGlobalSnoozeActive { scheduleAutoHide() } } + } } private extension View {