summaryrefslogtreecommitdiff
path: root/Hutch/Views/Inbox/InboxView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Inbox/InboxView.swift')
-rw-r--r--Hutch/Views/Inbox/InboxView.swift125
1 files changed, 125 insertions, 0 deletions
diff --git a/Hutch/Views/Inbox/InboxView.swift b/Hutch/Views/Inbox/InboxView.swift
new file mode 100644
index 0000000..304e62d
--- /dev/null
+++ b/Hutch/Views/Inbox/InboxView.swift
@@ -0,0 +1,125 @@
+import SwiftUI
+
+struct InboxView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: InboxViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading inbox…")
+ }
+ }
+ .navigationTitle("Inbox")
+ .task {
+ if viewModel == nil {
+ let vm = InboxViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadThreads()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: InboxViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.threads) { thread in
+ NavigationLink(value: thread) {
+ InboxThreadRow(thread: thread)
+ }
+ .swipeActions(edge: .leading, allowsFullSwipe: true) {
+ readStateAction(for: thread, in: viewModel)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ readStateAction(for: thread, in: viewModel)
+ }
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.threads.isEmpty {
+ SRHTLoadingStateView(message: "Loading inbox…")
+ } else if let error = viewModel.error, viewModel.threads.isEmpty {
+ SRHTErrorStateView(
+ title: "Failed to load inbox",
+ message: error,
+ retryAction: { await viewModel.loadThreads() }
+ )
+ } else if viewModel.threads.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "Inbox Zero",
+ systemImage: "tray",
+ description: Text("Unread threads will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.threads.isEmpty) {
+ await viewModel.loadThreads()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadThreads()
+ }
+ .navigationDestination(for: InboxThreadSummary.self) { thread in
+ ThreadDetailView(thread: thread) {
+ viewModel.markThreadRead(thread)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func readStateAction(for thread: InboxThreadSummary, in viewModel: InboxViewModel) -> some View {
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ viewModel.markThreadRead(thread)
+ }
+ } label: {
+ Label("Mark as Read", systemImage: "envelope.open")
+ }
+ .tint(.blue)
+ }
+}
+
+struct InboxThreadRow: View {
+ let thread: InboxThreadSummary
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ Circle()
+ .fill(thread.isUnread ? .blue : .clear)
+ .frame(width: 8, height: 8)
+ .padding(.top, 6)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(thread.displaySubject)
+ .font(.subheadline.weight(thread.isUnread ? .semibold : .medium))
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ if thread.containsPatch {
+ Image(systemName: "arrow.triangle.branch")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Text(thread.metadataLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+
+ Spacer(minLength: 8)
+
+ Text(thread.lastActivityAt.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary.opacity(0.7))
+ .lineLimit(1)
+ }
+ .padding(.vertical, 2)
+ }
+}