Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway: an approval push for a file change says which files it would edit
This commit is contained in:
1 parent
4dc4e57dac
commit
edc4b88990
35 files changed
+1162
-144
No files matched your search
@@ -0,0 +1,236 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user