krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.8.0: 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}