Skip to content

Commit 4f933cc

Browse files
Lokimortysteipete
andauthored
fix(ios): gateway error shows twice on the Settings Gateway page (#98856)
* fix(ios): show gateway errors once as a swipeable animated toast Gateway connection errors on the Settings > Gateway page appeared twice: as the app-wide toast and again as an embedded banner section. Remove the embedded banner (and its now-dead sheet/action helpers) so problems surface only via the root toast, animate the toast in smoothly from the top, and support swipe-up to dismiss with re-arm on new problems. * fix(ios): re-surface gateway toast on repeated problem reports A swiped-away toast stayed hidden when a retry failed with an identical problem, because value equality alone produces no observable change. The model now counts every problem report; a report while hidden re-shows the toast, and a report while visible shakes it instead of stacking a new one. * fix(ios): keep reset-onboarding action on the gateway toast and sync i18n inventory The root toast is now the only gateway problem surface outside covers, so it must keep the reset-onboarding primary action the removed settings banner had: auth-token-mismatch problems now show Reset onboarding and run the full GatewayOnboardingReset flow. Also refreshes the native i18n inventory for the changed Swift strings. * fix(ios): preserve gateway problem recovery state --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 574604e commit 4f933cc

9 files changed

Lines changed: 336 additions & 270 deletions

apps/.i18n/native-source.json

Lines changed: 156 additions & 164 deletions
Large diffs are not rendered by default.

apps/ios/Sources/Design/SettingsProTab.swift

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ struct SettingsProTab: View {
4646
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
4747
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
4848
@State var defaultShareInstruction = ""
49-
@State var showGatewayProblemDetails = false
5049
@State var showQRScanner = false
5150
@State var scannerError: String?
5251
@State var showResetOnboardingAlert = false
@@ -176,23 +175,18 @@ struct SettingsProTab: View {
176175
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
177176
self.applyPendingGatewaySetupLinkIfNeeded()
178177
}
178+
.onChange(of: self.onboardingRequestID) { _, _ in
179+
// Root-owned resets leave Settings mounted behind onboarding.
180+
// Reload cleared credentials before the view can persist stale state.
181+
self.syncAfterOnboardingReset()
182+
}
179183
.onChange(of: self.navigationPath) { _, _ in
180184
self.notifyRouteChange()
181185
}
182186
}
183187

184188
private func settingsModalPresentation(_ content: some View) -> some View {
185189
content
186-
.sheet(isPresented: self.$showGatewayProblemDetails) {
187-
if let gatewayProblem = self.appModel.lastGatewayProblem {
188-
GatewayProblemDetailsSheet(
189-
problem: gatewayProblem,
190-
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
191-
onPrimaryAction: {
192-
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
193-
})
194-
}
195-
}
196190
.sheet(isPresented: self.$showTalkIssueDetails) {
197191
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
198192
TalkRuntimeIssueDetailsSheet(issue: issue)

apps/ios/Sources/Design/SettingsProTabActions.swift

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ extension SettingsProTab {
173173
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
174174
}
175175

176+
func syncAfterOnboardingReset() {
177+
self.connectingGatewayID = nil
178+
self.setupStatusText = nil
179+
self.stagedGatewaySetupLink = nil
180+
self.pendingManualAuthOverride = nil
181+
self.syncSettingsState()
182+
}
183+
176184
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
177185
self.connectingGatewayID = gateway.id
178186
defer { self.connectingGatewayID = nil }
@@ -347,37 +355,6 @@ extension SettingsProTab {
347355
self.onboardingRequestID += 1
348356
}
349357

350-
func retryGatewayConnectionFromProblem() async {
351-
if self.manualGatewayEnabled || self.connectingGatewayID == "manual" {
352-
await self.connectManual()
353-
} else {
354-
await self.gatewayController.connectLastKnown()
355-
}
356-
}
357-
358-
func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String? {
359-
GatewayProblemPrimaryAction.title(
360-
for: problem,
361-
retryTitle: "Retry connection",
362-
resetTitle: "Reset onboarding")
363-
}
364-
365-
func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async {
366-
if problem.suggestsOnboardingReset {
367-
self.resetOnboarding()
368-
return
369-
}
370-
if problem.canTrustRotatedCertificate {
371-
_ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem)
372-
return
373-
}
374-
if GatewayProblemPrimaryAction.openProtocolMismatchHelpIfNeeded(problem) {
375-
return
376-
}
377-
guard problem.retryable else { return }
378-
await self.retryGatewayConnectionFromProblem()
379-
}
380-
381358
func handleLocationModeChange(_ newValue: String) {
382359
guard !self.isChangingLocationMode else { return }
383360
guard newValue != self.previousLocationModeRaw else { return }

apps/ios/Sources/Design/SettingsProTabSections.swift

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,6 @@ extension SettingsProTab {
200200

201201
var gatewayDestination: some View {
202202
VStack(alignment: .leading, spacing: 14) {
203-
if let gatewayProblem = self.appModel.lastGatewayProblem {
204-
self.gatewayProblemCard(gatewayProblem)
205-
}
206-
207203
self.detailStatusCard(
208204
icon: "antenna.radiowaves.left.and.right",
209205
title: "Gateway",
@@ -909,21 +905,6 @@ extension SettingsProTab {
909905
.padding(.horizontal, OpenClawProMetric.pagePadding)
910906
}
911907

912-
func gatewayProblemCard(_ problem: GatewayConnectionProblem) -> some View {
913-
ProCard(radius: SettingsLayout.cardRadius) {
914-
GatewayProblemBanner(
915-
problem: problem,
916-
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(problem),
917-
onPrimaryAction: {
918-
Task { await self.handleGatewayProblemPrimaryAction(problem) }
919-
},
920-
onShowDetails: {
921-
self.showGatewayProblemDetails = true
922-
})
923-
}
924-
.padding(.horizontal, OpenClawProMetric.pagePadding)
925-
}
926-
927908
func settingsToggle(
928909
_ title: String,
929910
isOn: Binding<Bool>,

apps/ios/Sources/Design/SettingsProTabSupport.swift

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -278,11 +278,6 @@ private struct SettingsGatewayStatesPreview: View {
278278
}
279279

280280
self.stateSection("Error") {
281-
GatewayProblemBanner(
282-
problem: Self.pairingProblem,
283-
primaryActionTitle: "Retry",
284-
onPrimaryAction: {},
285-
onShowDetails: {})
286281
self.gatewayStatusCard(
287282
title: "Tailscale warning",
288283
detail: "Tailscale is off on this device. Turn it on, then try again.",
@@ -395,15 +390,5 @@ private struct SettingsGatewayStatesPreview: View {
395390
.controlSize(.small)
396391
.disabled(isBusy)
397392
}
398-
399-
private static let pairingProblem = GatewayConnectionProblem(
400-
kind: .pairingRequired,
401-
owner: .gateway,
402-
title: "Pairing required",
403-
message: "Run /pair approve in your OpenClaw chat before this iPad can connect.",
404-
actionCommand: "/pair approve req-ipad-preview",
405-
requestId: "req-ipad-preview",
406-
retryable: false,
407-
pauseReconnect: true)
408393
}
409394
#endif

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,13 @@ final class NodeAppModel {
146146
// multiple pending requests and cause the onboarding UI to "flip-flop".
147147
var gatewayPairingPaused: Bool = false
148148
var gatewayPairingRequestId: String?
149-
private(set) var lastGatewayProblem: GatewayConnectionProblem?
149+
// Bumped on every non-nil assignment, including re-reports of an equal problem;
150+
// value equality alone cannot tell the UI to re-surface or shake the toast.
151+
private(set) var gatewayProblemReportCount = 0
152+
private(set) var lastGatewayProblem: GatewayConnectionProblem? {
153+
didSet { if self.lastGatewayProblem != nil { self.gatewayProblemReportCount &+= 1 } }
154+
}
155+
150156
private var operatorGatewayProblem: GatewayConnectionProblem?
151157
var gatewayDisplayStatusText: String {
152158
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText

apps/ios/Sources/RootTabs.swift

Lines changed: 110 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ struct RootTabs: View {
3737
@State private var toastDismissTask: Task<Void, Never>?
3838
@State private var presentedSheet: PresentedSheet?
3939
@State private var showGatewayProblemDetails: Bool = false
40+
@State private var gatewayToastDragOffset: CGFloat = 0
41+
// Swipe-up hides the toast only until the next problem report; every report
42+
// (even an equal problem) must re-surface it or shake the visible toast.
43+
@State private var isGatewayToastSwipeDismissed: Bool = false
44+
@State private var gatewayToastShake: CGFloat = 0
45+
// Mirror of the problem at the last handled report, used to tell a first
46+
// appearance (animate in) from a re-report while visible (shake).
47+
@State private var lastReportedGatewayProblem: GatewayConnectionProblem?
4048
@State private var showOnboarding: Bool = false
4149
@State private var onboardingAllowSkip: Bool = true
4250
@State private var didEvaluateOnboarding: Bool = false
@@ -598,28 +606,20 @@ struct RootTabs: View {
598606
private func rootOverlays(_ content: some View) -> some View {
599607
content
600608
.overlay(alignment: .top) {
601-
if let gatewayProblem = self.appModel.lastGatewayProblem,
602-
self.gatewayStatus != .connected
603-
{
604-
GatewayProblemBanner(
605-
problem: gatewayProblem,
606-
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
607-
onPrimaryAction: {
608-
self.handleGatewayProblemPrimaryAction(gatewayProblem)
609-
},
610-
onShowDetails: {
611-
self.showGatewayProblemDetails = true
612-
})
613-
.padding(.horizontal, 12)
614-
.safeAreaPadding(.top, 10)
615-
.transition(.move(edge: .top).combined(with: .opacity))
609+
// Stable container so the toast's move/opacity transition animates
610+
// when the gateway problem appears or clears outside withAnimation.
611+
ZStack(alignment: .top) {
612+
if let gatewayProblem = self.activeGatewayProblemToast {
613+
self.gatewayProblemToast(gatewayProblem)
614+
}
616615
}
616+
.animation(self.gatewayToastAnimation, value: self.activeGatewayProblemToast)
617617
}
618618
.overlay(alignment: .topLeading) {
619619
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
620620
VoiceWakeToast(command: voiceWakeToastText)
621621
.padding(.leading, 10)
622-
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
622+
.safeAreaPadding(.top, self.activeGatewayProblemToast == nil ? 58 : 132)
623623
.transition(.move(edge: .top).combined(with: .opacity))
624624
}
625625
}
@@ -638,6 +638,69 @@ struct RootTabs: View {
638638
}
639639
}
640640

641+
private var activeGatewayProblemToast: GatewayConnectionProblem? {
642+
// Operator-scope auth/pairing failures can coexist with a connected node.
643+
// The problem itself, not aggregate gateway status, owns toast visibility.
644+
guard let problem = self.appModel.lastGatewayProblem,
645+
!self.isGatewayToastSwipeDismissed
646+
else { return nil }
647+
return problem
648+
}
649+
650+
private var gatewayToastAnimation: Animation? {
651+
self.reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.85)
652+
}
653+
654+
private func gatewayProblemToast(_ problem: GatewayConnectionProblem) -> some View {
655+
GatewayProblemBanner(
656+
problem: problem,
657+
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(problem),
658+
onPrimaryAction: {
659+
self.handleGatewayProblemPrimaryAction(problem)
660+
},
661+
onShowDetails: {
662+
self.showGatewayProblemDetails = true
663+
})
664+
.padding(.horizontal, 12)
665+
.safeAreaPadding(.top, 10)
666+
.offset(y: min(self.gatewayToastDragOffset, 0))
667+
.modifier(GatewayToastShakeEffect(animatableData: self.gatewayToastShake))
668+
.gesture(self.gatewayToastSwipeGesture)
669+
// A drag cancelled by toast removal never fires onEnded; clear the
670+
// offset so the next toast doesn't render shifted up.
671+
.onDisappear { self.gatewayToastDragOffset = 0 }
672+
.transition(.move(edge: .top).combined(with: .opacity))
673+
}
674+
675+
private var gatewayToastSwipeGesture: some Gesture {
676+
DragGesture(minimumDistance: 12)
677+
.onChanged { value in
678+
self.gatewayToastDragOffset = value.translation.height
679+
}
680+
.onEnded { value in
681+
let swipedUp = value.translation.height < -32 || value.predictedEndTranslation.height < -80
682+
withAnimation(self.gatewayToastAnimation) {
683+
if swipedUp {
684+
self.isGatewayToastSwipeDismissed = true
685+
}
686+
self.gatewayToastDragOffset = 0
687+
}
688+
}
689+
}
690+
691+
private func handleGatewayProblemReport() {
692+
let toastWasVisible = self.lastReportedGatewayProblem != nil && !self.isGatewayToastSwipeDismissed
693+
self.lastReportedGatewayProblem = self.appModel.lastGatewayProblem
694+
if self.isGatewayToastSwipeDismissed {
695+
self.isGatewayToastSwipeDismissed = false
696+
return
697+
}
698+
guard toastWasVisible, self.activeGatewayProblemToast != nil else { return }
699+
withAnimation(self.reduceMotion ? nil : .linear(duration: 0.4)) {
700+
self.gatewayToastShake += 1
701+
}
702+
}
703+
641704
private var canvasPresentationOverlay: some View {
642705
ZStack(alignment: .topTrailing) {
643706
Color.black.ignoresSafeArea()
@@ -694,6 +757,7 @@ struct RootTabs: View {
694757
private func rootAppearLifecycle(_ content: some View) -> some View {
695758
content
696759
.onAppear { self.updateIdleTimer() }
760+
.onAppear { self.lastReportedGatewayProblem = self.appModel.lastGatewayProblem }
697761
.onAppear { self.updateCanvasState() }
698762
.onAppear { self.evaluateOnboardingPresentation(force: false) }
699763
.onAppear { self.maybeAutoOpenSettings() }
@@ -722,8 +786,21 @@ struct RootTabs: View {
722786
}
723787
}
724788

725-
private func rootGatewayLifecycle(_ content: some View) -> some View {
789+
private func rootGatewayProblemLifecycle(_ content: some View) -> some View {
726790
content
791+
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
792+
if newValue == nil {
793+
self.isGatewayToastSwipeDismissed = false
794+
self.lastReportedGatewayProblem = nil
795+
}
796+
}
797+
.onChange(of: self.appModel.gatewayProblemReportCount) { _, _ in
798+
self.handleGatewayProblemReport()
799+
}
800+
}
801+
802+
private func rootGatewayLifecycle(_ content: some View) -> some View {
803+
self.rootGatewayProblemLifecycle(content)
727804
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
728805
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
729806
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
@@ -1058,11 +1135,16 @@ extension RootTabs {
10581135
GatewayProblemPrimaryAction.title(
10591136
for: problem,
10601137
retryTitle: "Retry",
1138+
resetTitle: "Reset onboarding",
10611139
nonRetryableTitle: "Open Settings")
10621140
}
10631141

10641142
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) {
1065-
if problem.canTrustRotatedCertificate {
1143+
if problem.suggestsOnboardingReset {
1144+
// Reset bumps onboarding.requestID, which re-presents the wizard.
1145+
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId") ?? ""
1146+
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: instanceId)
1147+
} else if problem.canTrustRotatedCertificate {
10661148
Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) }
10671149
} else if GatewayProblemPrimaryAction.openProtocolMismatchHelpIfNeeded(problem) {
10681150
return
@@ -1221,6 +1303,16 @@ private struct RootTabsHomeCanvasAgentCard: Codable {
12211303
var isActive: Bool
12221304
}
12231305

1306+
/// Horizontal shake for re-reported gateway problems: three oscillations that
1307+
/// settle back to identity at integer trigger values.
1308+
private struct GatewayToastShakeEffect: GeometryEffect {
1309+
var animatableData: CGFloat
1310+
1311+
func effectValue(size _: CGSize) -> ProjectionTransform {
1312+
ProjectionTransform(CGAffineTransform(translationX: 7 * sin(self.animatableData * 6 * .pi), y: 0))
1313+
}
1314+
}
1315+
12241316
private struct RootCameraFlashOverlay: View {
12251317
var nonce: Int
12261318

apps/ios/Tests/GatewayProblemPrimaryActionTests.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ struct GatewayProblemPrimaryActionTests {
1919
#expect(title == "Update app")
2020
}
2121

22+
@Test func `reset-suggesting problem uses reset title when provided`() {
23+
let problem = GatewayConnectionProblem(
24+
kind: .gatewayAuthTokenMismatch,
25+
owner: .iphone,
26+
title: "Stored gateway token rejected",
27+
message: "Reset onboarding to pair again.",
28+
retryable: false,
29+
pauseReconnect: true)
30+
31+
let title = GatewayProblemPrimaryAction.title(
32+
for: problem,
33+
retryTitle: "Retry",
34+
resetTitle: "Reset onboarding",
35+
nonRetryableTitle: "Open Settings")
36+
37+
#expect(title == "Reset onboarding")
38+
}
39+
2240
@Test func `retryable problem uses mapped action label`() {
2341
let problem = GatewayConnectionProblem(
2442
kind: .timeout,

0 commit comments

Comments
 (0)