diff --git a/TalerCommon/HelperViews/TimeView.swift b/TalerCommon/HelperViews/TimeView.swift index a969250d..1cecc8ff 100644 --- a/TalerCommon/HelperViews/TimeView.swift +++ b/TalerCommon/HelperViews/TimeView.swift @@ -20,8 +20,14 @@ struct TimeView: View { self._isFinished = isFinished } + /// Seconds left, never below zero. + /// + /// The countdown is driven by a TimelineView that keeps ticking while + /// `isFinished` is flipped asynchronously, so without the clamp the view + /// renders "Next retry in: -1 seconds" and keeps counting down into the + /// negatives until the state change lands. func remainingTime(_ elapsed: Int) -> Int { - let remaining = seconds - elapsed + let remaining = max(0, seconds - elapsed) if remaining <= 0 { if isFinished != nil { DispatchQueue.main.async { diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift index b0864c85..57370783 100644 --- a/TalerWallet1/Views/HelperViews/LoadingView.swift +++ b/TalerWallet1/Views/HelperViews/LoadingView.swift @@ -28,6 +28,7 @@ struct StalledView: View { } } .buttonStyle(TalerButtonStyle(type: .bordered, disabled: disabled)) + .accessibilityIdentifier(A11yID.stalledRetryButton) .padding(.horizontal) } @@ -63,17 +64,42 @@ struct StalledView: View { if let lastPhase = controller.lastProgressPhase { if lastPhase.phase == .stalled { if let lastError, let delay = lastError.nextRetryDelay { + // The three states cross-fade, so all of them stay in + // the hierarchy. opacity(0) hides a view from sight + // but not from VoiceOver, which otherwise reads all + // three at once: "Next retry in: 3 seconds", + // "Retrying...", "Waiting for response...". + // + // .hidden() rather than .accessibilityHidden(), and + // rather than dropping the inactive branches: measured + // on the stalled screen, 43 samples per build, + // + // opacity only 3 states in the tree + // + .accessibilityHidden 3 states -- no effect here + // if/else, no inactive 1 state, but the band + // between "Copy Details" and + // "Retry now" shrinks by 32pt + // and jitters 5pt per flip + // if/else + .hidden() 1 state, frames identical + // to the unpatched build + // + // The ZStack is here to keep the row from resizing as + // the state changes, so the inactive branches have to + // keep their space -- which is exactly what .hidden() + // does and what removing them does not. ZStack { - TimeView("Next retry in:", seconds: Int(delay.toSeconds()), isFinished: $isFinished) + let countdown = TimeView("Next retry in:", seconds: Int(delay.toSeconds()), isFinished: $isFinished) .talerFont(.title3) .padding(.vertical) - .opacity(isFinished == false ? 1 : 0) - Text("Retrying…") + if isFinished == false { countdown } else { countdown.hidden() } + + let retrying = Text("Retrying…") .talerFont(.title3) - .opacity(isFinished == true ? 1 : 0) - Text("Waiting for response…") + if isFinished == true { retrying } else { retrying.hidden() } + + let waiting = Text("Waiting for response…") .talerFont(.title3) - .opacity(isFinished == nil ? 1 : 0) + if isFinished == nil { waiting } else { waiting.hidden() } } retryButton(disabled: isFinished ?? false) } @@ -135,6 +161,7 @@ struct LoadingView: View { } } .buttonStyle(TalerButtonStyle(type: .bordered, disabled: false)) + .accessibilityIdentifier(A11yID.loadingCancelButton) .padding(.horizontal) }