aboutsummaryrefslogtreecommitdiff
path: root/Hutch/Extensions/ErrorViews.swift
blob: 086c0de7fac33b1fd0847289003049f4277ba795 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import SwiftUI

// MARK: - SRHTErrorBanner ViewModifier

/// Displays a dismissible error banner at the top of the screen.
/// Attach to any view with `.srhtErrorBanner(error:)`.
struct SRHTErrorBanner: ViewModifier {
    @Binding var error: String?

    func body(content: Content) -> some View {
        content
            .overlay(alignment: .top) {
                if let message = error {
                    banner(message)
                        .transition(.move(edge: .top).combined(with: .opacity))
                }
            }
            .animation(.easeInOut(duration: 0.3), value: error)
    }

    @ViewBuilder
    private func banner(_ message: String) -> some View {
        HStack(spacing: 8) {
            Image(systemName: "exclamationmark.triangle.fill")
                .foregroundStyle(.white)

            Text(message)
                .font(.subheadline)
                .foregroundStyle(.white)
                .lineLimit(3)

            Spacer()

            Button {
                error = nil
            } label: {
                Image(systemName: "xmark.circle.fill")
                    .foregroundStyle(.white.opacity(0.8))
            }
        }
        .padding(12)
        .background(Color.red.gradient, in: RoundedRectangle(cornerRadius: 12))
        .padding(.horizontal, 12)
        .padding(.top, 4)
    }
}

extension View {
    /// Attach an error banner that shows at the top when `error` is non-nil.
    func srhtErrorBanner(error: Binding<String?>) -> some View {
        modifier(SRHTErrorBanner(error: error))
    }
}

// MARK: - Shared Screen States

struct SRHTLoadingStateView: View {
    let message: String

    var body: some View {
        VStack(spacing: 12) {
            ProgressView()
            Text(message)
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

struct SRHTErrorStateView: View {
    let title: String
    let message: String
    let retryAction: (() async -> Void)?

    var body: some View {
        ContentUnavailableView {
            SwiftUI.Label(title, systemImage: "exclamationmark.triangle")
        } description: {
            Text(message)
        } actions: {
            if let retryAction {
                Button("Retry") {
                    Task { await retryAction() }
                }
                .buttonStyle(.borderedProminent)
            }
        }
    }
}

// MARK: - NoConnectionView

/// Empty state view shown when the device is offline. Includes a retry button.
struct NoConnectionView: View {
    var retryAction: () async -> Void

    var body: some View {
        ContentUnavailableView {
            SwiftUI.Label("No Connection", systemImage: "wifi.slash")
        } description: {
            Text("Check your internet connection and try again.")
        } actions: {
            Button {
                Task { await retryAction() }
            } label: {
                Text("Retry")
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

// MARK: - Connectivity Overlay

/// ViewModifier that shows NoConnectionView when the device is offline and
/// there is no content to display. When content exists, shows a subtle
/// offline indicator instead.
struct ConnectivityOverlay: ViewModifier {
    @Environment(NetworkMonitor.self) private var networkMonitor
    let hasContent: Bool
    var retryAction: () async -> Void

    func body(content: Content) -> some View {
        content
            .overlay {
                if !networkMonitor.isConnected, !hasContent {
                    NoConnectionView(retryAction: retryAction)
                }
            }
            .safeAreaInset(edge: .bottom) {
                if !networkMonitor.isConnected, hasContent {
                    offlineBadge
                }
            }
    }

    private var offlineBadge: some View {
        HStack(spacing: 6) {
            Image(systemName: "wifi.slash")
                .font(.caption2)
            Text("Offline — showing cached data")
                .font(.caption2)
        }
        .foregroundStyle(.white)
        .padding(.horizontal, 12)
        .padding(.vertical, 6)
        .background(.orange.gradient, in: Capsule())
        .padding(.bottom, 4)
    }
}

extension View {
    /// Overlay a no-connection view when offline with no content, or a
    /// subtle offline badge when showing cached data.
    func connectivityOverlay(hasContent: Bool, retryAction: @escaping () async -> Void) -> some View {
        modifier(ConnectivityOverlay(hasContent: hasContent, retryAction: retryAction))
    }
}