[auth-payment-login-lockout] Make auth intro a clean screen with a back button
App Review rejected the 2026-08 build: tapping checkout as a guest switched root to .auth, which replaced the whole UI with LoginView as the NavigationStack root - no nav bar, no back, no dismiss. User was trapped. - LoginView: rebuilt as a static screen in LCENavigationView with a back button that sets root = .main, a 'Criar conta' and an 'Entrar' button, and 'Termos de Uso' / 'Politica de Privacidade' links. Added #Preview. - Forced .preferredColorScheme(.light) and fixed DS colors (the app has no dark theme, so system dark mode was inverting the nav bar / title). - Removed the entry-reveal animation machinery (heroVisible/textVisible/ buttonVisible/token/prepare flags across LoginView, AuthFlowView, ContentView) - dead since guest browsing shipped and the root cause of the 2026-08-06 'content stuck hidden' bug. - enterAuthFlow() is now just root = .auth. - UITestSupport.ensureLoggedIn taps 'Entrar' (new label). - ProfileLoggedOutFlowTests: new test covering the choices and the way back out of .auth. - Localizable.xcstrings: catalog caught up to the new/removed strings.
This commit is contained in:
@@ -15,8 +15,6 @@ struct ContentView: View {
|
|||||||
@State var hasPerformedInitialLaunchBootstrap = false
|
@State var hasPerformedInitialLaunchBootstrap = false
|
||||||
@State var showLaunchSplash = true
|
@State var showLaunchSplash = true
|
||||||
@State var shouldPulseLaunchSplash = true
|
@State var shouldPulseLaunchSplash = true
|
||||||
@State var shouldPrepareAuthEntryAnimation = DefaultTokenStore().jwt == nil
|
|
||||||
@State var authEntryAnimationToken = 0
|
|
||||||
@State private var sessionExpiredObserver: NSObjectProtocol?
|
@State private var sessionExpiredObserver: NSObjectProtocol?
|
||||||
@State var cartResetObserver: Any?
|
@State var cartResetObserver: Any?
|
||||||
@State var appResumeObserver: Any?
|
@State var appResumeObserver: Any?
|
||||||
@@ -32,9 +30,7 @@ struct ContentView: View {
|
|||||||
root: $root,
|
root: $root,
|
||||||
selectedTab: $selectedTab,
|
selectedTab: $selectedTab,
|
||||||
tokenStore: tokenStore,
|
tokenStore: tokenStore,
|
||||||
appState: $appState,
|
appState: $appState
|
||||||
shouldPrepareLoginEntry: shouldPrepareAuthEntryAnimation,
|
|
||||||
authEntryAnimationToken: authEntryAnimationToken
|
|
||||||
)
|
)
|
||||||
case .main:
|
case .main:
|
||||||
if isBootstrappingSession {
|
if isBootstrappingSession {
|
||||||
@@ -136,15 +132,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if root == .auth {
|
if root == .auth {
|
||||||
shouldPrepareAuthEntryAnimation = true
|
|
||||||
shouldPulseLaunchSplash = false
|
shouldPulseLaunchSplash = false
|
||||||
try? await Task.sleep(nanoseconds: 180_000_000)
|
try? await Task.sleep(nanoseconds: 180_000_000)
|
||||||
withAnimation(.easeInOut(duration: 0.34)) {
|
withAnimation(.easeInOut(duration: 0.34)) {
|
||||||
showLaunchSplash = false
|
showLaunchSplash = false
|
||||||
}
|
}
|
||||||
try? await Task.sleep(nanoseconds: 360_000_000)
|
|
||||||
authEntryAnimationToken += 1
|
|
||||||
scheduleDisableAuthEntryPreparation()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,17 +402,13 @@ struct ContentView: View {
|
|||||||
enterAuthFlow()
|
enterAuthFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The only correct way to transition `root` to `.auth`: pairs the
|
/// The single funnel for entering the auth flow. `LoginView` is a
|
||||||
/// switch with the token bump `LoginView` needs to run its entry
|
/// static screen with its own `LCENavigationView` back button (sets
|
||||||
/// reveal animation. A bare `root = .auth` leaves the login screen's
|
/// `root = .main`), so no animation priming is needed here - see
|
||||||
/// hero/text/button stuck hidden - see
|
/// decisions/2026-08-27-payment-auth-lockout-clean-intro-screen.md.
|
||||||
/// decisions/2026-08-06-login-entry-animation-bug.md.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func enterAuthFlow() {
|
private func enterAuthFlow() {
|
||||||
shouldPrepareAuthEntryAnimation = true
|
|
||||||
root = .auth
|
root = .auth
|
||||||
authEntryAnimationToken += 1
|
|
||||||
scheduleDisableAuthEntryPreparation()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -463,13 +451,6 @@ struct ContentView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleDisableAuthEntryPreparation() {
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: 900_000_000)
|
|
||||||
shouldPrepareAuthEntryAnimation = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hasConfiguredAddress() -> Bool {
|
private func hasConfiguredAddress() -> Bool {
|
||||||
if appState.address.selectedId != nil {
|
if appState.address.selectedId != nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -88,6 +88,9 @@
|
|||||||
},
|
},
|
||||||
"Abrir Ajustes" : {
|
"Abrir Ajustes" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Acesse sua conta" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Acompanhamento em tempo real" : {
|
"Acompanhamento em tempo real" : {
|
||||||
|
|
||||||
@@ -346,10 +349,6 @@
|
|||||||
"comment" : "A prompt asking users to share their opinion about the delivery.",
|
"comment" : "A prompt asking users to share their opinion about the delivery.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Criar conta" : {
|
|
||||||
"comment" : "A link that navigates to the registration screen.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Crie sua conta" : {
|
"Crie sua conta" : {
|
||||||
"comment" : "A link that directs users to create an account.",
|
"comment" : "A link that directs users to create an account.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -748,9 +747,6 @@
|
|||||||
"Não recebeu o código?" : {
|
"Não recebeu o código?" : {
|
||||||
"comment" : "A question displayed below the button to re-send the OTP.",
|
"comment" : "A question displayed below the button to re-send the OTP.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
|
||||||
"Não tem conta ainda?" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Nenhum cartão cadastrado" : {
|
"Nenhum cartão cadastrado" : {
|
||||||
|
|
||||||
|
|||||||
@@ -11,21 +11,12 @@ struct AuthFlowView: View {
|
|||||||
@Binding var selectedTab: MainTab
|
@Binding var selectedTab: MainTab
|
||||||
let tokenStore: TokenStore
|
let tokenStore: TokenStore
|
||||||
@Binding var appState: AppState
|
@Binding var appState: AppState
|
||||||
var shouldPrepareLoginEntry: Bool = false
|
|
||||||
var authEntryAnimationToken: Int = 0
|
|
||||||
|
|
||||||
@State var path: [Route] = []
|
@State var path: [Route] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $path) {
|
NavigationStack(path: $path) {
|
||||||
LoginView(
|
LoginView(root: $root) { route in
|
||||||
root: $root,
|
|
||||||
selectedTab: $selectedTab,
|
|
||||||
tokenStore: tokenStore,
|
|
||||||
appState: $appState,
|
|
||||||
shouldPrepareEntryAnimation: shouldPrepareLoginEntry,
|
|
||||||
authEntryAnimationToken: authEntryAnimationToken
|
|
||||||
) { route in
|
|
||||||
path.append(route)
|
path.append(route)
|
||||||
}
|
}
|
||||||
.navigationDestination(for: Route.self) { route in
|
.navigationDestination(for: Route.self) { route in
|
||||||
|
|||||||
@@ -1,142 +1,79 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if canImport(LCEssentials)
|
||||||
|
import LCEssentials
|
||||||
|
#endif
|
||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@Binding var root: RootFlow
|
@Binding var root: RootFlow
|
||||||
@Binding var selectedTab: MainTab
|
|
||||||
let tokenStore: TokenStore
|
|
||||||
@Binding var appState: AppState
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
var shouldPrepareEntryAnimation: Bool = false
|
|
||||||
var authEntryAnimationToken: Int = 0
|
|
||||||
let navigate: (Route) -> Void
|
let navigate: (Route) -> Void
|
||||||
@State var heroVisible = true
|
|
||||||
@State var textVisible = true
|
|
||||||
@State var buttonVisible = true
|
|
||||||
@State var lastAnimatedToken = 0
|
|
||||||
|
|
||||||
@ViewBuilder private var logoImage: some View {
|
@ViewBuilder private var logoImage: some View {
|
||||||
SwiftUI.Image("pedifoods")
|
SwiftUI.Image("pedifoods")
|
||||||
.resizable()
|
.resizable()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var pinHeroImage: some View {
|
var body: some View {
|
||||||
SwiftUI.Image("pin_image_app")
|
LCENavigationView {
|
||||||
.resizable()
|
content
|
||||||
|
}
|
||||||
|
.setLeftButton(image: AnyView(AppBackButtonIcon())) { root = .main }
|
||||||
|
.setTitle(text: Text("Acesse sua conta").font(AppTypography.heading2).foregroundStyle(AppColors.textPrimary))
|
||||||
|
.setNavigationBarBackgroundColor(AppColors.backgroundLight)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
// The app ships no dark theme; force light so system dark mode
|
||||||
|
// can't invert the nav bar / title against the fixed DS colors.
|
||||||
|
.preferredColorScheme(.light)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
private var content: some View {
|
||||||
GeometryReader { geo in
|
VStack(spacing: 20) {
|
||||||
let heroHeight = max(360, geo.size.height * 0.44)
|
Spacer()
|
||||||
let logoTopInset = max(0, (geo.size.height - 180) / 2)
|
|
||||||
|
|
||||||
ZStack(alignment: .top) {
|
|
||||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
pinHeroImage
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(height: heroHeight + 80)
|
|
||||||
.offset(y: heroVisible ? -60 : -(heroHeight + 220))
|
|
||||||
.mask(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.black, .black, .black.opacity(0.0)],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
VStack(spacing: 18) {
|
|
||||||
Spacer().frame(height: logoTopInset)
|
|
||||||
|
|
||||||
logoImage
|
logoImage
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(height: 180)
|
.frame(height: 160)
|
||||||
|
|
||||||
Text("Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque.")
|
Text("Sua vontade, no seu tempo.\nTudo o que você precisa, em um toque.")
|
||||||
.font(AppTypography.heading25)
|
.font(AppTypography.heading25)
|
||||||
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.center)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.horizontal, 28)
|
.padding(.horizontal, 28)
|
||||||
.opacity(textVisible ? 1.0 : 0.0)
|
|
||||||
.offset(y: textVisible ? 0 : 24)
|
|
||||||
|
|
||||||
Spacer().frame(height: 40)
|
Spacer()
|
||||||
|
|
||||||
Button {
|
VStack(spacing: 12) {
|
||||||
navigate(.loginEmail)
|
PrimaryButton(title: "Criar conta", image: Image(systemName: "arrow.right")) {
|
||||||
} label: {
|
|
||||||
PrimaryButtonLabel(title: "ENTRAR")
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 28)
|
|
||||||
.tint(AppColors.tertiary)
|
|
||||||
.offset(y: buttonVisible ? 0 : 140)
|
|
||||||
.opacity(buttonVisible ? 1.0 : 0.0)
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text("Não tem conta ainda?")
|
|
||||||
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary)
|
|
||||||
Button("Criar conta") {
|
|
||||||
navigate(.registration)
|
navigate(.registration)
|
||||||
}
|
}
|
||||||
|
.tint(AppColors.tertiary)
|
||||||
|
|
||||||
|
SecondaryButton(title: "Entrar") {
|
||||||
|
navigate(.loginEmail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
|
||||||
|
legalLinks
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var legalLinks: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Button("Termos de Uso") { navigate(.terms) }
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Text("·")
|
||||||
|
Button("Política de Privacidade") { navigate(.policy) }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(AppColors.primary)
|
|
||||||
}
|
|
||||||
.font(AppTypography.body)
|
|
||||||
.opacity(textVisible ? 1.0 : 0.0)
|
|
||||||
.offset(y: textVisible ? 0 : 24)
|
|
||||||
|
|
||||||
Spacer().frame(height: 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.onAppear {
|
|
||||||
if shouldPrepareEntryAnimation {
|
|
||||||
applyHiddenStateWithoutAnimation()
|
|
||||||
} else {
|
|
||||||
showFinalStateWithoutAnimation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task(id: authEntryAnimationToken) {
|
|
||||||
await runEntryAnimationIfNeeded(for: authEntryAnimationToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func applyHiddenStateWithoutAnimation() {
|
|
||||||
heroVisible = false
|
|
||||||
textVisible = false
|
|
||||||
buttonVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func showFinalStateWithoutAnimation() {
|
|
||||||
heroVisible = true
|
|
||||||
textVisible = true
|
|
||||||
buttonVisible = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func runEntryAnimationIfNeeded(for token: Int) async {
|
|
||||||
guard token > 0 else { return }
|
|
||||||
guard token != lastAnimatedToken else { return }
|
|
||||||
lastAnimatedToken = token
|
|
||||||
|
|
||||||
applyHiddenStateWithoutAnimation()
|
|
||||||
try? await Task.sleep(nanoseconds: 40_000_000)
|
|
||||||
|
|
||||||
withAnimation(.spring(response: 0.64, dampingFraction: 0.9)) {
|
|
||||||
heroVisible = true
|
|
||||||
}
|
|
||||||
try? await Task.sleep(nanoseconds: 160_000_000)
|
|
||||||
withAnimation(.easeOut(duration: 0.42)) {
|
|
||||||
textVisible = true
|
|
||||||
}
|
|
||||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
||||||
withAnimation(.spring(response: 0.52, dampingFraction: 0.86)) {
|
|
||||||
buttonVisible = true
|
|
||||||
}
|
}
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
LoginView(root: .constant(.auth), navigate: { _ in })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
/// Covers the Profile tab's logged-out state (`ProfileLoggedOutView`) -
|
/// Covers the Profile tab's logged-out state (`ProfileLoggedOutView`) and
|
||||||
/// confirmed reachable. Actually logging in is blocked by a separate
|
/// the auth intro screen (`LoginView`) it opens.
|
||||||
/// navigation issue in LoginView's entry-reveal animation, documented in
|
|
||||||
/// decisions/2026-08-06-ui-test-account-and-app-attest-bypass.md - not
|
|
||||||
/// covered here.
|
|
||||||
final class ProfileLoggedOutFlowTests: XCTestCase {
|
final class ProfileLoggedOutFlowTests: XCTestCase {
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
@@ -20,18 +17,27 @@ final class ProfileLoggedOutFlowTests: XCTestCase {
|
|||||||
XCTAssertTrue(app.buttons["Entrar ou Cadastrar"].isHittable)
|
XCTAssertTrue(app.buttons["Entrar ou Cadastrar"].isHittable)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTappingLoginButtonDoesNotCrash() throws {
|
/// The auth intro screen must offer both entry points plus the legal
|
||||||
|
/// links, and — the reason App Review rejected the build in 2026-08 —
|
||||||
|
/// a working way back out (its `LCENavigationView` back button, which
|
||||||
|
/// sets `root = .main`).
|
||||||
|
func testAuthIntroScreenShowsChoicesAndCanGoBack() throws {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launch()
|
app.launch()
|
||||||
XCTAssertTrue(app.reachLoggedOutProfile())
|
XCTAssertTrue(app.reachLoggedOutProfile())
|
||||||
|
|
||||||
app.buttons["Entrar ou Cadastrar"].tap()
|
app.buttons["Entrar ou Cadastrar"].tap()
|
||||||
sleep(2)
|
|
||||||
|
|
||||||
// Known limitation (see decisions doc): the login screen's entry
|
XCTAssertTrue(app.buttons["Criar conta"].waitForExistence(timeout: 5), "Auth intro missing 'Criar conta'")
|
||||||
// animation doesn't reliably reveal when navigating here from an
|
XCTAssertTrue(app.buttons["Entrar"].exists, "Auth intro missing 'Entrar'")
|
||||||
// already-running guest session, so this only asserts the app is
|
XCTAssertTrue(app.buttons["Termos de Uso"].exists, "Auth intro missing 'Termos de Uso' link")
|
||||||
// still alive and responsive, not that login itself completes.
|
XCTAssertTrue(app.buttons["Política de Privacidade"].exists, "Auth intro missing 'Política de Privacidade' link")
|
||||||
XCTAssertEqual(app.state, .runningForeground)
|
|
||||||
|
let backButton = app.buttons.matching(NSPredicate(format: "label == %@", "Back")).firstMatch
|
||||||
|
XCTAssertTrue(backButton.exists, "Auth intro has no back button — user is trapped")
|
||||||
|
backButton.tap()
|
||||||
|
|
||||||
|
// Back out of `.auth` lands on the main tab bar again.
|
||||||
|
XCTAssertTrue(app.buttons["Home"].waitForExistence(timeout: 5), "Back button did not return to the main flow")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ extension XCUIApplication {
|
|||||||
|
|
||||||
buttons["Entrar ou Cadastrar"].tap()
|
buttons["Entrar ou Cadastrar"].tap()
|
||||||
|
|
||||||
let entrarButton = buttons["ENTRAR"]
|
let entrarButton = buttons["Entrar"]
|
||||||
guard entrarButton.waitForExistence(timeout: 5) else { return false }
|
guard entrarButton.waitForExistence(timeout: 5) else { return false }
|
||||||
entrarButton.tap()
|
entrarButton.tap()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user