Buttons.swift (14760B)
1 /* 2 * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. 3 * See LICENSE.md 4 */ 5 /** 6 * @author Marc Stibane 7 */ 8 import SwiftUI 9 import Foundation 10 import AVFoundation 11 12 extension ShapeStyle where Self == Color { 13 static var random: Color { 14 Color( 15 red: .random(in: 0...1), 16 green: .random(in: 0...1), 17 blue: .random(in: 0...1) 18 ) 19 } 20 } 21 22 struct HamburgerButton : View { 23 let action: () -> Void 24 25 var body: some View { 26 Button(action: action) { 27 Image(systemName: HAMBURGER) 28 // Image(systemName: "sidebar.squares.leading") // 29 } 30 .talerFont(.title) 31 .accessibilityLabel(Text("Main Menu", comment: "a11y")) 32 } 33 } 34 35 struct LinkButton: View { 36 let destination: URL 37 let hintTitle: String 38 let buttonTitle: String 39 let a11yHint: String 40 let badge: String 41 42 @AppStorage("minimalistic") var minimalistic: Bool = false 43 44 var body: some View { 45 VStack(alignment: .leading) { 46 if !minimalistic { // show hint that the user should authorize on bank website 47 Text(hintTitle) 48 .fixedSize(horizontal: false, vertical: true) // wrap in scrollview 49 .multilineTextAlignment(.leading) // otherwise 50 .listRowSeparator(.hidden) 51 } 52 Link(destination: destination) { 53 HStack(spacing: 8.0) { 54 Image(systemName: LINK) 55 Text(buttonTitle) 56 } 57 } 58 .buttonStyle(TalerButtonStyle(type: .prominent, badge: badge)) 59 .accessibilityHint(a11yHint) 60 } 61 } 62 } 63 64 struct QRButton : View { 65 let hideTitle: Bool 66 let action: () -> Void 67 68 @AppStorage("minimalistic") var minimalistic: Bool = false 69 @State private var showCameraAlert: Bool = false 70 71 private var openSettingsButton: some View { 72 Button("Open Settings") { 73 showCameraAlert = false 74 UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) 75 } 76 } 77 let closingAnnouncement = String(localized: "Closing Camera", comment: "a11y") 78 79 var defaultPriorityAnnouncement = String(localized: "Opening Camera", comment: "a11y") 80 81 var highPriorityAnnouncement: AttributedString { 82 var highPriorityString = AttributedString(localized: "Camera Active", comment: "a11y") 83 if #available(iOS 17.0, *) { 84 highPriorityString.accessibilitySpeechAnnouncementPriority = .high 85 } 86 return highPriorityString 87 } 88 @MainActor 89 private func checkCameraAvailable() -> Void { 90 // Open Camera when QR-Button was tapped 91 announce(defaultPriorityAnnouncement) 92 93 AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) -> Void in 94 if granted { 95 action() 96 if #available(iOS 17.0, *) { 97 AccessibilityNotification.Announcement(highPriorityAnnouncement).post() 98 } else { 99 let cameraActive = String(localized: "Camera Active", comment: "a11y") 100 announce(cameraActive) 101 } 102 } else { 103 showCameraAlert = true 104 } 105 }) 106 } 107 108 var body: some View { 109 let dismissAlertButton = Button("Cancel", role: .cancel) { 110 announce(closingAnnouncement) 111 showCameraAlert = false 112 } 113 let scanText = String(localized: "Scan QR code", comment: "Button title, a11y") 114 let qrImage = Image(systemName: QRBUTTON) 115 let qrText = Text(qrImage) 116 Button(action: checkCameraAvailable) { 117 if hideTitle { 118 qrImage 119 .resizable() 120 .scaledToFit() 121 .foregroundStyle(Color.accentColor) 122 } else if minimalistic { 123 let width = UIScreen.screenWidth / 7 124 qrText.talerFont(.largeTitle) 125 .padding(.horizontal, width) 126 } else { 127 HStack(spacing: 16) { 128 qrText.talerFont(.title) 129 Text(scanText) 130 }.padding(.horizontal) 131 } 132 } 133 .accessibilityLabel(scanText) 134 .alert("Scanning QR-codes requires access to the camera", 135 isPresented: $showCameraAlert, 136 actions: { openSettingsButton 137 dismissAlertButton }, 138 message: { Text("Please allow camera access in settings.") }) // Scanning QR-codes 139 } 140 } 141 142 struct DoneButton : View { 143 let titleStr: String? 144 let accessibilityLabelStr: String 145 let action: () -> Void 146 147 var body: some View { 148 Button(action: action) { 149 if let titleStr { 150 Text(titleStr) 151 .tint(.accentColor) 152 } else { 153 Image(systemName: CHECKMARK) // 154 } 155 } 156 .tint(.accentColor) 157 .talerFont(.title) 158 .accessibilityLabel(accessibilityLabelStr) 159 } 160 } 161 162 struct PlusButton : View { 163 let accessibilityLabelStr: String 164 let action: () -> Void 165 166 var body: some View { 167 Button(action: action) { 168 Image(systemName: PLUS) 169 } 170 .tint(.accentColor) 171 .talerFont(.title) 172 .accessibilityLabel(accessibilityLabelStr) 173 } 174 } 175 176 struct SettingsButton : View { 177 let accessibilityLabelStr: String 178 let action: () -> Void 179 180 var body: some View { 181 let button = Button(action: action) { 182 Image(systemName: SETTINGS) 183 } 184 .tint(.accentColor) 185 .talerFont(.title) 186 .accessibilityLabel(accessibilityLabelStr) 187 #if compiler(>=6.2) // XCODE26 188 if #available(iOS 26.0, *) { 189 return button.buttonStyle(.glass) 190 } 191 #endif 192 return button 193 } 194 } 195 196 struct ZoomInButton : View { 197 let accessibilityLabelStr: String 198 let action: () -> Void 199 200 var body: some View { 201 Button(action: action) { 202 let name = ICONNAME_ZOOM_IN 203 if UIImage(named: name) != nil { 204 Image(name) 205 } else { 206 Image(systemName: SYSTEM_ZOOM_IN) 207 } 208 } 209 .tint(.accentColor) 210 .talerFont(.title) 211 .accessibilityLabel(accessibilityLabelStr) 212 } 213 } 214 215 struct ZoomOutButton : View { 216 let accessibilityLabelStr: String 217 let action: () -> Void 218 219 var body: some View { 220 Button(action: action) { 221 let name = ICONNAME_ZOOM_OUT 222 if UIImage(named: name) != nil { 223 Image(name) 224 } else { 225 Image(systemName: SYSTEM_ZOOM_OUT) 226 } 227 } 228 .tint(.accentColor) 229 .talerFont(.title) 230 .accessibilityLabel(accessibilityLabelStr) 231 } 232 } 233 234 struct BackButton : View { 235 let action: () -> Void 236 237 var body: some View { 238 Button(action: action) { 239 let name = ICONNAME_INCOMING + ICONNAME_FILL 240 let sysName = SYSTEM_INCOMING4 + ICONNAME_FILL 241 if UIImage(named: name) != nil { 242 Image(name) 243 } else if UIImage(systemName: sysName) != nil { 244 Image(systemName: sysName) 245 } else { 246 // TODO: ultralight vs black 247 Image(systemName: FALLBACK_INCOMING) 248 } 249 } 250 .tint(.accentColor) 251 .talerFont(.largeTitle) 252 .accessibilityLabel(Text("Back", comment: "a11y")) 253 } 254 } 255 256 struct ForwardButton : View { 257 let enabled: Bool 258 let action: () -> Void 259 260 var body: some View { 261 let myAction = { 262 if enabled { 263 action() 264 } 265 } 266 Button(action: myAction) { 267 let imageName = enabled ? ICONNAME_OUTGOING + ICONNAME_FILL 268 : ICONNAME_OUTGOING 269 let sysName = enabled ? SYSTEM_OUTGOING4 + ICONNAME_FILL 270 : SYSTEM_OUTGOING4 271 if UIImage(named: imageName) != nil { 272 Image(imageName) 273 } else if UIImage(systemName: sysName) != nil { 274 Image(systemName: sysName) 275 } else { 276 // TODO: ultralight vs black 277 Image(systemName: FALLBACK_OUTGOING) 278 } 279 } 280 .tint(.accentColor) 281 .talerFont(.largeTitle) 282 .accessibilityLabel(Text("Continue", comment: "a11y")) 283 } 284 } 285 286 struct ArrowUpButton : View { 287 let action: () -> Void 288 289 var body: some View { 290 Button(action: action) { 291 Image(systemName: ARROW_TOP) // 292 } 293 .tint(.accentColor) 294 .talerFont(.title2) 295 .accessibilityLabel(Text("Scroll up", comment: "a11y")) 296 } 297 } 298 299 struct ArrowDownButton : View { 300 let action: () -> Void 301 302 var body: some View { 303 Button(action: action) { 304 Image(systemName: ARROW_BOT) // 305 } 306 .tint(.accentColor) 307 .talerFont(.title2) 308 .accessibilityLabel(Text("Scroll down", comment: "a11y")) 309 } 310 } 311 312 struct ReloadButton : View { 313 let disabled: Bool 314 let action: () -> Void 315 316 var body: some View { 317 Button(action: action) { 318 Image(systemName: RELOAD) // 319 } 320 .tint(.accentColor) 321 .talerFont(.title) 322 .accessibilityLabel(Text("Reload", comment: "a11y")) 323 .disabled(disabled) 324 } 325 } 326 327 struct TalerButtonStyle: ButtonStyle { 328 enum TalerButtonStyleType { 329 case plain 330 case bordered 331 case prominent 332 } 333 var type: TalerButtonStyleType = .plain 334 var dimmed: Bool = false 335 var narrow: Bool = false 336 var disabled: Bool = false 337 var aligned: TextAlignment = .center 338 var badge: String = EMPTYSTRING 339 340 @Environment(\.colorScheme) private var colorScheme 341 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 342 343 public func makeBody(configuration: ButtonStyleConfiguration) -> some View { 344 // configuration.role = type == .prominent ? .primary : .normal Only on macOS 345 MyBigButton(foreColor: foreColor(type: type, pressed: configuration.isPressed, 346 scheme: colorScheme, contrast: colorSchemeContrast, disabled: disabled), 347 backColor: backColor(type: type, pressed: configuration.isPressed, disabled: disabled), 348 dimmed: dimmed, 349 configuration: configuration, 350 disabled: disabled, 351 narrow: narrow, 352 aligned: aligned, 353 badge: badge, 354 scheme: colorScheme, 355 contrast: colorSchemeContrast) 356 } 357 358 func foreColor(type: TalerButtonStyleType, 359 pressed: Bool, 360 scheme: ColorScheme, 361 contrast: ColorSchemeContrast, 362 disabled: Bool) -> Color { 363 if type == .plain { 364 return WalletColors().fieldForeground // primary text color 365 } 366 return WalletColors().buttonForeColor(pressed: pressed, 367 disabled: disabled, 368 scheme: scheme, 369 contrast: contrast, 370 prominent: type == .prominent) 371 } 372 func backColor(type: TalerButtonStyleType, pressed: Bool, disabled: Bool) -> Color { 373 if type == .plain && !pressed { 374 return Color.clear 375 } 376 return WalletColors().buttonBackColor(pressed: pressed, 377 disabled: disabled, 378 prominent: type == .prominent) 379 } 380 381 struct BackgroundView: View { 382 let color: Color 383 let dimmed: Bool 384 var body: some View { 385 RoundedRectangle( 386 cornerRadius: 15, 387 style: .continuous 388 ) 389 .fill(color) 390 .opacity(dimmed ? 0.6 : 1.0) 391 } 392 } 393 394 struct MyBigButton: View { 395 // var type: TalerButtonStyleType 396 let foreColor: Color 397 let backColor: Color 398 let dimmed: Bool 399 let configuration: ButtonStyle.Configuration 400 let disabled: Bool 401 let narrow: Bool 402 let aligned: TextAlignment 403 var badge: String 404 var scheme: ColorScheme 405 var contrast: ColorSchemeContrast 406 407 var body: some View { 408 let aligned2: Alignment = (aligned == .center) ? Alignment.center 409 : (aligned == .leading) ? Alignment.leading 410 : Alignment.trailing 411 let hasBadge = badge.count > 0 412 let buttonLabel = configuration.label 413 .multilineTextAlignment(aligned) 414 .talerFont(.title3) // narrow ? .title3 : .title2 415 .frame(maxWidth: narrow ? nil : .infinity, alignment: aligned2) 416 .padding(.vertical, 10) 417 .padding(.horizontal, hasBadge ? 0 : 6) 418 .foregroundColor(foreColor) 419 .background(BackgroundView(color: backColor, dimmed: dimmed)) 420 .contentShape(Rectangle()) // make sure the button can be pressed even if backgroundColor == clear 421 .scaleEffect(configuration.isPressed ? 0.95 : 1) 422 .animation(.spring(response: 0.1), value: configuration.isPressed) 423 .disabled(disabled) 424 if hasBadge { 425 let badgeColor: Color = (badge == CONFIRM_BANK) ? WalletColors().confirm 426 : WalletColors().attention 427 let badgeV = Image(systemName: badge) 428 .talerFont(.caption) 429 HStack(alignment: .top, spacing: 0) { 430 badgeV.foregroundColor(.clear) 431 buttonLabel 432 badgeV.foregroundColor(badgeColor) 433 } 434 } else { 435 buttonLabel 436 } 437 } 438 } 439 } 440 // MARK: - 441 #if DEBUG 442 fileprivate struct ContentView_Previews: PreviewProvider { 443 static var previews: some View { 444 let testButtonTitle = String("Placeholder") 445 Button(testButtonTitle) {} 446 .buttonStyle(TalerButtonStyle(type: .bordered, aligned: .trailing)) 447 } 448 } 449 #endif