krz/hutch

an ios client for sourcehut

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

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