237 lines
8.7 KiB
Swift
237 lines
8.7 KiB
Swift
// 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)
|
|
}
|