taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }