ErrorView.swift (12011B)
1 /* 2 * This file is part of GNU Taler, ©2022-26 Taler Systems S.A. 3 * See LICENSE.md 4 */ 5 /** 6 * @author Iván Ávalos 7 * @author Marc Stibane 8 */ 9 import SwiftUI 10 import taler_swift 11 12 enum BannerType { 13 case error 14 case networkUnavailable 15 case warning5sec 16 } 17 18 struct ErrorBanner: View { 19 let type: BannerType 20 21 init(_ type: BannerType) { 22 self.type = type 23 } 24 25 var errorText: String { 26 switch type { 27 case .error: 28 return "An error occurred…" 29 case .networkUnavailable: 30 return "Network unavailable!" 31 case .warning5sec: 32 return "Network is slow…" 33 } 34 } 35 36 var errorColor: Color { 37 switch type { 38 case .error: 39 return .red 40 case .networkUnavailable: 41 return .red 42 case .warning5sec: 43 return .yellow 44 } 45 } 46 47 @ScaledMetric var frameHeight: CGFloat = 60 // relative to fontSize 48 49 var body: some View { 50 let text = Text(errorText) 51 .foregroundStyle(.white) 52 .talerFont(.title2) 53 Group { 54 if #available(iOS 26.0, *) { 55 ContainerRelativeShape() 56 .fill(errorColor) 57 .frame(height: frameHeight) 58 .overlay { 59 ZStack { 60 VStack(spacing: 0) { 61 Color.clear 62 Rectangle() 63 .fill(errorColor) 64 } 65 text 66 } 67 } 68 } else { // ContainerRelativeShape exists since iOS 14, but doesn't work correctly 69 text 70 .frame(maxWidth: .infinity, alignment: .center) 71 .frame(height: frameHeight) 72 .background { 73 let radius = 12.0 74 if #available(iOS 16.4, *) { 75 let radii = RectangleCornerRadii(topLeading: radius, 76 bottomLeading: 0, 77 bottomTrailing: 0, 78 topTrailing: radius) 79 UnevenRoundedRectangle(cornerRadii: radii, style: .continuous) 80 .fill(errorColor) 81 } else { 82 RoundedRectangle(cornerRadius: radius) 83 .fill(errorColor) 84 .overlay { 85 VStack(spacing: 0) { 86 Color.clear 87 Rectangle() 88 .fill(errorColor) 89 } 90 } 91 } 92 } 93 } 94 } 95 .transition(.move(edge: .top).combined(with: .scale)) 96 } 97 } 98 99 enum ErrorData { 100 case message(title: String, message: String) 101 case error(Error) 102 } 103 104 struct ErrorView: View { 105 106 /// wallet-core errors that are expected outcomes, rather than faults, keyed by `TalerErrorCode`. 107 static func titleForExpected(code: Int) -> String? { 108 switch code { 109 case 7054: // WALLET_KYC_LIMIT_EXCEEDED 110 String(localized: "Limit exceeded", 111 comment: "error title: the payment service refused the amount") 112 case 7055: // WALLET_NO_SUITABLE_EXCHANGE 113 String(localized: "No suitable payment service", 114 comment: "error title: no exchange can serve this request") 115 case 7064: // WALLET_CONTRACT_TERMS_UNSUPPORTED 116 String(localized: "Transaction Error") 117 case 7000, // WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE 118 7017: // WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE 119 String(localized: "Incompatible version", 120 comment: "error title: the server speaks a protocol this app does not") 121 default: nil 122 } 123 } 124 125 let stack: CallStack? 126 var title: String 127 var exclam: Bool 128 var message: String? = nil 129 var requestUrl: String? = nil 130 var copyable: Bool 131 132 var onDismiss: (() -> Void)? 133 134 init(_ stack: CallStack?, title: String, exclam: Bool = true, message: String? = nil, 135 requestUrl: String? = nil, copyable: Bool = false, onDismiss: (() -> Void)? = nil) { 136 self.stack = stack 137 self.title = title 138 self.exclam = exclam 139 self.message = message 140 self.requestUrl = requestUrl 141 self.copyable = copyable 142 self.onDismiss = onDismiss 143 } 144 145 init(_ stack: CallStack?, error: Error, devMode: Bool, onDismiss: (() -> Void)? = nil) { 146 let internetError = String(localized: "Network problem") 147 let internetMessage = String(localized: "Please check your internet connection and try again.") 148 let walletCoreError = String(localized: "Internal core error") 149 let initializationError = String(localized: "Initialization error") 150 let serializationError = String(localized: "Serialization error") 151 let deserializationError = String(localized: "Deserialization error") 152 let wrongParameters = String(localized: "Wrong parameters") 153 let unknownError = String(localized: "Unknown error") 154 let userCancelled = String(localized: "User cancelled") 155 let transactionError = String(localized: "Transaction Error") 156 157 switch error { 158 case let walletError as WalletBackendError: 159 switch walletError { 160 case .walletCoreError(let walletBackendResponseError): 161 if let responseError = walletBackendResponseError { 162 let code = responseError.code 163 if code == 7002 || code == 7003 || code == 7005 || code == 7032 || code == 7036 || code == 7049 { 164 self.init(stack, 165 title: code == 7036 ? userCancelled 166 : code == 7049 ? transactionError : internetError, 167 exclam: code != 7036 && code != 7049, 168 message: responseError.hint ?? internetMessage, 169 requestUrl: responseError.requestUrl, 170 copyable: false, 171 onDismiss: onDismiss) 172 break 173 } 174 let hint = responseError.errorResponse?.hint ?? responseError.hint // nested one preferred 175 // Limits, quotas and similar conditions are ordinary outcomes the user can act on 176 if let expectedTitle = Self.titleForExpected(code: code) { 177 self.init(stack, title: expectedTitle, 178 exclam: false, 179 message: hint, 180 requestUrl: responseError.requestUrl, 181 copyable: devMode, 182 onDismiss: onDismiss) 183 break 184 } 185 if !devMode, let hint { 186 // prefer hint for regular users 187 self.init(stack, title: walletCoreError, message: hint, copyable: true, onDismiss: onDismiss) 188 break 189 } 190 if let json = responseError.toJSON() { 191 // raw JSON for developers, or if there is no hint 192 self.init(stack, title: walletCoreError, message: json, copyable: true, onDismiss: onDismiss) 193 break 194 } else if let hint { 195 // should never happen 196 self.init(stack, title: walletCoreError, message: hint, copyable: true, onDismiss: onDismiss) 197 break 198 // } else if let message = responseError.message { 199 // self.init(stack, title: walletCoreError, message: message, copyable: true, onDismiss: onDismiss) 200 // break 201 } 202 } 203 self.init(stack, title: walletCoreError, copyable: false, onDismiss: onDismiss) 204 case .initializationError: 205 self.init(stack, title: initializationError, copyable: false, onDismiss: onDismiss) 206 case .serializationError: 207 self.init(stack, title: serializationError, copyable: false, onDismiss: onDismiss) 208 case .deserializationError: 209 self.init(stack, title: deserializationError, copyable: false, onDismiss: onDismiss) 210 case .wrongParameters: 211 self.init(stack, title: wrongParameters, copyable: false, onDismiss: onDismiss) 212 } 213 default: 214 self.init(stack, title: unknownError, message: error.localizedDescription, copyable: false, onDismiss: onDismiss) 215 } 216 } 217 218 init(_ stack: CallStack?, data: ErrorData, devMode: Bool, onDismiss: (() -> Void)? = nil) { 219 switch data { 220 case .message(let title, let message): 221 self.init(stack, title: title, message: message, copyable: false, onDismiss: onDismiss) 222 case .error(let error): 223 self.init(stack, error: error, devMode: devMode, onDismiss: onDismiss) 224 } 225 } 226 227 var body: some View { 228 ScrollView { 229 VStack { 230 if exclam { 231 Image(systemName: EXCLAMATION) 232 .resizable() 233 .frame(width: 50, height: 50) 234 .aspectRatio(contentMode: .fit) 235 .foregroundStyle(WalletColors().attention) 236 .padding() 237 } 238 Text(title) 239 .talerFont(.title) 240 .padding(.bottom) 241 242 if let message { 243 if copyable { 244 if #available(iOS 16.4, *) { 245 Text(message).monospaced() 246 } else { 247 Text(message).font(.system(.body, design: .monospaced)) 248 } 249 250 CopyButton(message, vertical: false) 251 .accessibilityLabel(Text("Copy the error JSON", comment: "a11y")) 252 .padding() 253 } else { 254 if let requestUrl { 255 Text(requestUrl) 256 .multilineTextAlignment(.center) 257 .padding() 258 } 259 Text(message) 260 .multilineTextAlignment(.center) 261 .padding() 262 } 263 } 264 } 265 } 266 .padding() 267 .safeAreaInset(edge: .bottom) { 268 Button("OK", role: .cancel) { 269 if let onDismiss { 270 onDismiss() 271 } else { 272 dismissTop(stack?.push()) 273 } 274 } 275 .buttonStyle(TalerButtonStyle(type: .bordered)) 276 .padding(.bottom) 277 .padding(.horizontal) 278 } 279 } 280 }