@@ -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+
12241316private struct RootCameraFlashOverlay : View {
12251317 var nonce : Int
12261318
0 commit comments