diff options
Diffstat (limited to 'Hutch/Views/Inbox/InboxView.swift')
| -rw-r--r-- | Hutch/Views/Inbox/InboxView.swift | 125 |
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) + } +} |
