krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

main: Hutch/Extensions/ErrorViews.swift · raw

  1import SwiftUI
  2
  3// MARK: - SRHTErrorBanner ViewModifier
  4
  5/// Displays a dismissible error banner at the top of the screen.
  6/// Attach to any view with `.srhtErrorBanner(error:)`.
  7struct SRHTErrorBanner: ViewModifier {
  8    @Binding var error: String?
  9
 10    func body(content: Content) -> some View {
 11        content
 12            .overlay(alignment: .top) {
 13                if let message = error {
 14                    banner(message)
 15                        .transition(.move(edge: .top).combined(with: .opacity))
 16                }
 17            }
 18            .animation(.easeInOut(duration: 0.3), value: error)
 19    }
 20
 21    @ViewBuilder
 22    private func banner(_ message: String) -> some View {
 23        HStack(spacing: 8) {
 24            Image(systemName: "exclamationmark.triangle.fill")
 25                .foregroundStyle(.white)
 26
 27            Text(message)
 28                .font(.subheadline)
 29                .foregroundStyle(.white)
 30                .lineLimit(3)
 31
 32            Spacer()
 33
 34            Button {
 35                error = nil
 36            } label: {
 37                Image(systemName: "xmark.circle.fill")
 38                    .foregroundStyle(.white.opacity(0.8))
 39            }
 40            .accessibilityLabel("Dismiss error")
 41        }
 42        .padding(12)
 43        .background(Color.red.gradient, in: RoundedRectangle(cornerRadius: 12))
 44        .padding(.horizontal, 12)
 45        .padding(.top, 4)
 46    }
 47}
 48
 49extension View {
 50    /// Attach an error banner that shows at the top when `error` is non-nil.
 51    func srhtErrorBanner(error: Binding<String?>) -> some View {
 52        modifier(SRHTErrorBanner(error: error))
 53    }
 54}
 55
 56// MARK: - Shared Screen States
 57
 58struct SRHTLoadingStateView: View {
 59    let message: String
 60
 61    var body: some View {
 62        VStack(spacing: 12) {
 63            ProgressView()
 64            Text(message)
 65                .font(.subheadline)
 66                .foregroundStyle(.secondary)
 67        }
 68        .frame(maxWidth: .infinity, maxHeight: .infinity)
 69    }
 70}
 71
 72struct SRHTErrorStateView: View {
 73    let title: String
 74    let message: String
 75    let retryAction: (() async -> Void)?
 76
 77    var body: some View {
 78        ContentUnavailableView {
 79            SwiftUI.Label(title, systemImage: "exclamationmark.triangle")
 80        } description: {
 81            Text(message)
 82        } actions: {
 83            if let retryAction {
 84                Button("Retry") {
 85                    Task { await retryAction() }
 86                }
 87                .buttonStyle(.borderedProminent)
 88            }
 89        }
 90    }
 91}
 92
 93// MARK: - NoConnectionView
 94
 95/// Empty state view shown when the device is offline. Includes a retry button.
 96struct NoConnectionView: View {
 97    var retryAction: () async -> Void
 98
 99    var body: some View {
100        ContentUnavailableView {
101            SwiftUI.Label("No Connection", systemImage: "wifi.slash")
102        } description: {
103            Text("Check your internet connection and try again.")
104        } actions: {
105            Button {
106                Task { await retryAction() }
107            } label: {
108                Text("Retry")
109            }
110            .buttonStyle(.borderedProminent)
111        }
112    }
113}
114
115// MARK: - Connectivity Overlay
116
117/// ViewModifier that shows NoConnectionView when the device is offline and
118/// there is no content to display. When content exists, shows a subtle
119/// offline indicator instead.
120struct ConnectivityOverlay: ViewModifier {
121    @Environment(NetworkMonitor.self) private var networkMonitor
122    let hasContent: Bool
123    var retryAction: () async -> Void
124
125    func body(content: Content) -> some View {
126        content
127            .overlay {
128                if !networkMonitor.isConnected, !hasContent {
129                    NoConnectionView(retryAction: retryAction)
130                }
131            }
132            .safeAreaInset(edge: .bottom) {
133                if !networkMonitor.isConnected, hasContent {
134                    offlineBadge
135                }
136            }
137    }
138
139    private var offlineBadge: some View {
140        HStack(spacing: 6) {
141            Image(systemName: "wifi.slash")
142                .font(.caption2)
143            Text("Offline — showing cached data")
144                .font(.caption2)
145        }
146        .foregroundStyle(.white)
147        .padding(.horizontal, 12)
148        .padding(.vertical, 6)
149        .background(.orange.gradient, in: Capsule())
150        .padding(.bottom, 4)
151    }
152}
153
154extension View {
155    /// Overlay a no-connection view when offline with no content, or a
156    /// subtle offline badge when showing cached data.
157    func connectivityOverlay(hasContent: Bool, retryAction: @escaping () async -> Void) -> some View {
158        modifier(ConnectivityOverlay(hasContent: hasContent, retryAction: retryAction))
159    }
160}