// Snoozing a finished thread hides it from Recent until a time picked from four fixed choices, and // the gateway brings it back on its own. A Recent row's swipe and the thread screen's Snooze button // open the same picker. When any thread is snoozed, a dim violet row under Recent counts them and // opens the Snoozed list, where each card says when it wakes and swipes to Unsnooze. import SwiftUI /// One of the picker's times, worked out in the watch's time zone. struct SnoozeChoice: Hashable, Identifiable { var title: String var until: Date var id: String { title } /// In 1 hour, This evening at 6 PM (left out from 5 PM on), Tomorrow at 9 AM, and Next week, /// which is next Monday at 9 AM. static func choices(now: Date = .now, calendar: Calendar = .current) -> [SnoozeChoice] { let today = calendar.startOfDay(for: now) func at(_ hour: Int, daysAfter days: Int) -> Date? { calendar.date(byAdding: .day, value: days, to: today) .flatMap { calendar.date(bySettingHour: hour, minute: 0, second: 0, of: $0) } } var choices = [SnoozeChoice(title: "In 1 hour", until: now.addingTimeInterval(3600))] if calendar.component(.hour, from: now) < 17, let evening = at(18, daysAfter: 0) { choices.append(SnoozeChoice(title: "This evening · 6 PM", until: evening)) } if let tomorrow = at(9, daysAfter: 1) { choices.append(SnoozeChoice(title: "Tomorrow · 9 AM", until: tomorrow)) } // Days until the next Monday, a full week when today is Monday. Weekday 2 is Monday. let days = (9 - calendar.component(.weekday, from: now)) % 7 if let monday = at(9, daysAfter: days == 0 ? 7 : days) { choices.append(SnoozeChoice(title: "Next week", until: monday)) } return choices } } extension Format { /// When a snoozed thread comes back: "Wakes at 6 PM" today, "Wakes tomorrow 9 AM", "Wakes Monday /// 9 AM" within the week, and "Wakes Nov 30, 9 AM" past that. static func wakes(at date: Date, now: Date = .now, calendar: Calendar = .current) -> String { let style = Date.FormatStyle(locale: Locale(identifier: "en_US_POSIX"), calendar: calendar, timeZone: calendar.timeZone) // "6:00 PM" becomes "6 PM". Some formatters put a narrow space before PM. let time = date.formatted(style.hour(.defaultDigits(amPM: .abbreviated)).minute()) .replacingOccurrences(of: ":00", with: "") .replacingOccurrences(of: "\u{202F}", with: " ") let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: calendar.startOfDay(for: date)).day ?? 0 switch days { case ...0: return "Wakes at \(time)" case 1: return "Wakes tomorrow \(time)" case 2 ..< 7: return "Wakes \(date.formatted(style.weekday(.wide))) \(time)" default: let day = date.formatted(style.month(.abbreviated).day()) return "Wakes \(day), \(time)" } } } /// "Snooze until" and the four choices. Picking one snoozes the thread; on success the picker closes, /// and on failure it stays and says why. struct SnoozePicker: View { @Environment(AppModel.self) private var model var item: FeedItem /// Fixed for snapshots. Live, the choices are worked out when the picker opens. var now: Date = .now /// Snapshots render the choices without their ScrollView, which ImageRenderer leaves blank. var scrolls = true @State private var sending = false @State private var error: String? var body: some View { if scrolls { ScrollView { choices } .navigationTitle("Snooze until") } else { choices } } private var choices: some View { VStack(spacing: 6) { ForEach(SnoozeChoice.choices(now: now)) { choice in Button(choice.title) { snooze(until: choice.until) } .buttonStyle(PillButtonStyle(fill: Theme.card, foreground: .white)) } if let error { Text(error) .font(.system(size: 11)) .foregroundStyle(Theme.secondaryText) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) } } .disabled(sending) .opacity(sending ? 0.5 : 1) .padding(.horizontal, 4) } private func snooze(until: Date) { sending = true error = nil Task { do { try await model.snooze(item, until: until) } catch { self.error = model.message(for: error) } sending = false } } } /// The dim violet row under Recent, "☾ 2 snoozed", opening the Snoozed list. struct SnoozedRow: View { @Environment(AppModel.self) private var model var count: Int var body: some View { Button { model.path.append(.snoozed) } label: { Card(edge: Theme.snooze) { HStack(spacing: 5) { Image(systemName: "moon.fill") .font(.system(size: 11, weight: .bold)) Text("\(count) snoozed") .font(.system(size: 13, weight: .semibold)) Spacer(minLength: 4) Image(systemName: "chevron.right") .font(.system(size: 11, weight: .bold)) } .foregroundStyle(Theme.snooze) } .opacity(0.85) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("\(count) snoozed threads") } } /// Every snoozed thread, the soonest to wake first. It loads `GET /v1/snoozed` when it appears; /// nothing polls. struct SnoozedView: View { @Environment(AppModel.self) private var model /// Snapshots render the rows without their List, which ImageRenderer leaves blank. var scrolls = true var body: some View { Group { if scrolls { List { rows } .listStyle(.plain) .refreshable { await model.loadSnoozed() } .task { await model.loadSnoozed() } } else { VStack(spacing: 6) { rows } .padding(.horizontal, 4) } } .navigationTitle("Snoozed") } @ViewBuilder private var rows: some View { if let items = model.snoozed { ForEach(items) { item in SnoozedCard(item: item).feedRow() } if items.isEmpty { Text("Nothing snoozed.") .font(.system(size: 13)) .foregroundStyle(Theme.secondaryText) .padding(.horizontal, 8) .feedRow() } } if model.snoozed == nil || model.snoozedError != nil { LoadStatus(loaded: model.snoozed != nil, error: model.snoozedError, retry: model.loadSnoozed) .feedRow() } } } /// A snoozed thread: when it wakes in violet, its title, and where it runs. Opens the thread, and a /// left swipe unsnoozes it. private struct SnoozedCard: View { @Environment(AppModel.self) private var model var item: FeedItem var body: some View { Button { model.path.append(item.route) } label: { Card(edge: Theme.snooze) { if let until = item.snoozedUntil { Text(Format.wakes(at: until)) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.snooze) .lineLimit(1) } Text(item.title) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.secondaryText) .lineLimit(2) .padding(.top, 1) Text("\(item.hostLabel) · \(item.projectTitle)") .font(.system(size: 11)) .foregroundStyle(Theme.done) .lineLimit(1) .padding(.top, 2) } .contentShape(Rectangle()) } .buttonStyle(.plain) .swipeActions(edge: .trailing) { Button { Task { await model.unsnooze(item) } } label: { Label("Unsnooze", systemImage: "sun.max.fill") } .tint(Theme.snooze) } } } #Preview("Snoozed") { NavigationStack { SnoozedView() } .environment(AppModel.preview()) .fontDesign(.rounded) } #Preview("Snooze until") { NavigationStack { SnoozePicker(item: DemoFixtures.previewRecent()[0]) } .environment(AppModel.preview()) .fontDesign(.rounded) }