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}