// All threads, opened from the last row of the feed: hosts, then a host's projects sorted by title, // then a project's threads with settled ones included. Hosts and projects come from // `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`. Each screen loads // when it appears; nothing polls. A thread opens the usual thread screen. import SwiftUI /// Every host, offline ones dimmed. Each opens its projects. struct HostsView: 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 { BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) { if let hosts = model.projects?.hosts { ForEach(hosts) { host in BrowseRow( title: host.label, subtitle: host.status.problem ?? (host.projects.count == 1 ? "1 project" : "\(host.projects.count) projects"), route: .hostProjects(hostId: host.id)) .opacity(host.status == .online ? 1 : 0.5) .feedRow() } } } .navigationTitle("All threads") } } /// One host's projects, sorted by title. Each opens its threads. struct ProjectsView: View { @Environment(AppModel.self) private var model let hostId: String var scrolls = true var body: some View { let host = model.projects?.hosts.first { $0.id == hostId } BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) { if let host { let projects = host.projects.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } ForEach(projects) { project in BrowseRow(title: project.title, route: .projectThreads(hostId: host.id, projectId: project.id)) .feedRow() } if projects.isEmpty { BrowseNote(text: "No projects.") } } else if model.projects != nil { BrowseNote(text: "This host is gone.") } } .navigationTitle(host?.label ?? "Projects") } } /// One project's threads, newest first: a status dot, the title, dimmed once finished or settled, /// and the age. struct ProjectThreadsView: View { @Environment(AppModel.self) private var model let hostId: String let projectId: String @State private var rows: [ThreadRow]? @State private var error: String? private let scrolls: Bool /// `rows` starts the screen loaded, for snapshots. init(hostId: String, projectId: String, rows: [ThreadRow]? = nil, scrolls: Bool = true) { self.hostId = hostId self.projectId = projectId self.scrolls = scrolls _rows = State(initialValue: rows) } var body: some View { let title = model.projects?.hosts.first { $0.id == hostId }?.projects.first { $0.id == projectId }?.title BrowseList(scrolls: scrolls, error: error, loaded: rows != nil, reload: load) { if let rows { ForEach(rows) { row in ThreadLine(row: row) { Text(Format.elapsed(since: row.updatedAt)) .foregroundStyle(Theme.secondaryText) } .feedRow() } if rows.isEmpty { BrowseNote(text: "No threads yet.") } } } .navigationTitle(title ?? "Threads") .task { // The title comes from the projects list, which a wristcode:// link may skip. if model.projects == nil { await model.loadProjects() } } } private func load() async { do { rows = try await model.api.projectThreads(hostId: hostId, projectId: projectId).items error = nil } catch { self.error = model.message(for: error) } } } /// A thread on one line: status dot, title, and `trailing` on the right. Opens the thread. struct ThreadLine: View { @Environment(AppModel.self) private var model var row: ThreadRow @ViewBuilder var trailing: Trailing var body: some View { Button { model.path.append(row.route) } label: { Card(edge: Theme.card) { HStack(spacing: 7) { Circle() .fill(row.color) .frame(width: 7, height: 7) Text(row.title) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(row.isActive ? Color.white : Theme.secondaryText) .lineLimit(1) Spacer(minLength: 4) trailing .font(.system(size: 11, weight: .semibold)) .lineLimit(1) } } .contentShape(Rectangle()) } .buttonStyle(.plain) } } /// The List each browse screen scrolls, with loading, an error and Try again. Loads on appear. private struct BrowseList: View { var scrolls: Bool var error: String? var loaded: Bool var reload: () async -> Void @ViewBuilder var rows: Rows var body: some View { if scrolls { List { content } .listStyle(.plain) .refreshable { await reload() } .task { await reload() } } else { VStack(spacing: 6) { content } .padding(.horizontal, 4) } } @ViewBuilder private var content: some View { if loaded { rows } else if error == nil { ProgressView() .frame(maxWidth: .infinity) .padding(.top, 20) .feedRow() } if let error { BrowseNote(text: error) if !loaded { Button("Try again") { Task { await reload() } } .buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white)) .feedRow() } } } } /// A plain card with a title, an optional line under it, and a chevron. private struct BrowseRow: View { @Environment(AppModel.self) private var model var title: String var subtitle: String? var route: Route var body: some View { Button { model.path.append(route) } label: { Card(edge: Theme.done) { HStack { VStack(alignment: .leading, spacing: 1) { Text(title) .font(.system(size: 14, weight: .semibold)) .lineLimit(2) if let subtitle { Text(subtitle) .font(.system(size: 11)) .foregroundStyle(Theme.secondaryText) .lineLimit(1) } } Spacer(minLength: 4) Image(systemName: "chevron.right") .font(.system(size: 11, weight: .bold)) .foregroundStyle(Theme.secondaryText) } } .contentShape(Rectangle()) } .buttonStyle(.plain) } } private struct BrowseNote: View { var text: String var body: some View { Text(text) .font(.system(size: 12)) .foregroundStyle(Theme.secondaryText) .padding(.horizontal, 8) .feedRow() } } #Preview("Hosts") { NavigationStack { HostsView() } .environment(AppModel.preview()) .fontDesign(.rounded) } #Preview("Threads") { NavigationStack { ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac") } .environment(AppModel.preview()) .fontDesign(.rounded) }